💄 Better adjust lyrics experience
This commit is contained in:
@@ -160,9 +160,9 @@ class MusixmatchProvider extends LrcProvider {
|
|||||||
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
|
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
|
||||||
if (statusCode != 200) return null;
|
if (statusCode != 200) return null;
|
||||||
final body = jsonDecode(r.body)["message"]["body"];
|
final body = jsonDecode(r.body)["message"]["body"];
|
||||||
if (body == null || !(body is Map)) return null;
|
if (body == null || body is! Map) return null;
|
||||||
final tracks = body["track_list"];
|
final tracks = body["track_list"];
|
||||||
if (tracks == null || !(tracks is List) || tracks.isEmpty) return null;
|
if (tracks == null || tracks is! List || tracks.isEmpty) return null;
|
||||||
|
|
||||||
// Simple "best match" - first track
|
// Simple "best match" - first track
|
||||||
final track = tracks.firstWhere((t) => true, orElse: () => null);
|
final track = tracks.firstWhere((t) => true, orElse: () => null);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:groovybox/logic/audio_handler.dart';
|
import 'package:groovybox/logic/audio_handler.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Import lyrics if any
|
// Import lyrics if any
|
||||||
|
if (!context.mounted) return;
|
||||||
if (lyricsPaths.isNotEmpty) {
|
if (lyricsPaths.isNotEmpty) {
|
||||||
await _batchImportLyricsFromPaths(
|
await _batchImportLyricsFromPaths(
|
||||||
context,
|
context,
|
||||||
@@ -366,10 +367,12 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
final query = searchQuery.value.toLowerCase();
|
final query = searchQuery.value.toLowerCase();
|
||||||
filteredTracks = tracks.where((track) {
|
filteredTracks = tracks.where((track) {
|
||||||
if (track.title.toLowerCase().contains(query)) return true;
|
if (track.title.toLowerCase().contains(query)) return true;
|
||||||
if (track.artist?.toLowerCase().contains(query) ?? false)
|
if (track.artist?.toLowerCase().contains(query) ?? false) {
|
||||||
return true;
|
return true;
|
||||||
if (track.album?.toLowerCase().contains(query) ?? false)
|
}
|
||||||
|
if (track.album?.toLowerCase().contains(query) ?? false) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
if (track.lyrics != null) {
|
if (track.lyrics != null) {
|
||||||
try {
|
try {
|
||||||
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:drift/drift.dart' as drift;
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:groovybox/data/db.dart' as db;
|
import 'package:groovybox/data/db.dart' as db;
|
||||||
|
import 'package:groovybox/data/track_repository.dart';
|
||||||
|
import 'package:groovybox/logic/lrc_providers.dart';
|
||||||
import 'package:groovybox/logic/lyrics_parser.dart';
|
import 'package:groovybox/logic/lyrics_parser.dart';
|
||||||
import 'package:groovybox/logic/metadata_service.dart';
|
import 'package:groovybox/logic/metadata_service.dart';
|
||||||
import 'package:groovybox/providers/audio_provider.dart';
|
import 'package:groovybox/providers/audio_provider.dart';
|
||||||
@@ -63,7 +69,9 @@ class PlayerScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
BackdropFilter(
|
BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
||||||
child: Container(color: Colors.black.withOpacity(0.6)),
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -177,6 +185,7 @@ class _MobileLayout extends StatelessWidget {
|
|||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: trackPath,
|
||||||
),
|
),
|
||||||
ViewMode.lyrics => _LyricsView(
|
ViewMode.lyrics => _LyricsView(
|
||||||
key: const ValueKey('lyrics'),
|
key: const ValueKey('lyrics'),
|
||||||
@@ -239,6 +248,7 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: trackPath,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
@@ -286,6 +296,7 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: trackPath,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
@@ -303,19 +314,10 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: MediaQuery.sizeOf(context).width * 0.6,
|
width: MediaQuery.sizeOf(context).width * 0.6,
|
||||||
child: Stack(
|
child: Padding(
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
||||||
),
|
),
|
||||||
Positioned(
|
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
child: _LyricsRefreshButton(trackPath: trackPath),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -360,6 +362,7 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: trackPath,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
@@ -390,12 +393,14 @@ class _CoverView extends StatelessWidget {
|
|||||||
final Player player;
|
final Player player;
|
||||||
final AsyncValue<TrackMetadata> metadataAsync;
|
final AsyncValue<TrackMetadata> metadataAsync;
|
||||||
final Media media;
|
final Media media;
|
||||||
|
final String trackPath;
|
||||||
|
|
||||||
const _CoverView({
|
const _CoverView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.metadataAsync,
|
required this.metadataAsync,
|
||||||
required this.media,
|
required this.media,
|
||||||
|
required this.trackPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -416,6 +421,7 @@ class _CoverView extends StatelessWidget {
|
|||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: trackPath,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
@@ -661,27 +667,75 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
musixmatchProvider,
|
musixmatchProvider,
|
||||||
neteaseProvider,
|
neteaseProvider,
|
||||||
) {
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _FetchLyricsDialog(
|
||||||
|
track: track,
|
||||||
|
trackPath: trackPath,
|
||||||
|
metadataObj: metadataObj,
|
||||||
|
ref: ref,
|
||||||
|
musixmatchProvider: musixmatchProvider,
|
||||||
|
neteaseProvider: neteaseProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FetchLyricsDialog extends StatelessWidget {
|
||||||
|
final db.Track track;
|
||||||
|
final String trackPath;
|
||||||
|
final dynamic metadataObj;
|
||||||
|
final WidgetRef ref;
|
||||||
|
final LrcProvider musixmatchProvider;
|
||||||
|
final LrcProvider neteaseProvider;
|
||||||
|
|
||||||
|
const _FetchLyricsDialog({
|
||||||
|
required this.track,
|
||||||
|
required this.trackPath,
|
||||||
|
required this.metadataObj,
|
||||||
|
required this.ref,
|
||||||
|
required this.musixmatchProvider,
|
||||||
|
required this.neteaseProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final metadata = metadataObj as TrackMetadata?;
|
final metadata = metadataObj as TrackMetadata?;
|
||||||
final searchTerm =
|
final searchTerm =
|
||||||
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
|
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
showDialog(
|
return AlertDialog(
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Fetch Lyrics'),
|
title: const Text('Fetch Lyrics'),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
Text('Search term: $searchTerm'),
|
RichText(
|
||||||
const SizedBox(height: 16),
|
text: TextSpan(
|
||||||
Text('Choose a provider:'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
_ProviderButton(
|
const TextSpan(text: 'Search lyrics with '),
|
||||||
name: 'Musixmatch',
|
TextSpan(
|
||||||
onPressed: () async {
|
text: searchTerm,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text('Where do you want to search lyrics from?'),
|
||||||
|
Card(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.library_music),
|
||||||
|
title: const Text('Musixmatch'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await ref
|
await ref
|
||||||
.read(lyricsFetcherProvider.notifier)
|
.read(lyricsFetcherProvider.notifier)
|
||||||
@@ -693,10 +747,14 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
ListTile(
|
||||||
_ProviderButton(
|
dense: true,
|
||||||
name: 'NetEase',
|
leading: const Icon(Icons.music_video),
|
||||||
onPressed: () async {
|
title: const Text('NetEase'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await ref
|
await ref
|
||||||
.read(lyricsFetcherProvider.notifier)
|
.read(lyricsFetcherProvider.notifier)
|
||||||
@@ -708,8 +766,21 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.file_upload),
|
||||||
|
title: const Text('Manual Import'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _importLyricsForTrack(context, ref, track, trackPath);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -718,29 +789,50 @@ class _PlayerLyrics extends HookConsumerWidget {
|
|||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importLyricsForTrack(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
db.Track track,
|
||||||
|
String trackPath,
|
||||||
|
) async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['lrc', 'srt', 'txt'],
|
||||||
|
allowMultiple: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = File(result.files.first.path!);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final filename = result.files.first.name;
|
||||||
|
|
||||||
|
final lyricsData = LyricsParser.parse(content, filename);
|
||||||
|
final lyricsJson = lyricsData.toJsonString();
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(trackRepositoryProvider.notifier)
|
||||||
|
.updateLyrics(track.id, lyricsJson);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${lyricsData.lines.length} lyrics lines for "${track.title}"',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProviderButton extends StatelessWidget {
|
|
||||||
final String name;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
const _ProviderButton({required this.name, required this.onPressed});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Expanded(
|
|
||||||
child: ElevatedButton(onPressed: onPressed, child: Text(name)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LyricsRefreshButton extends HookConsumerWidget {
|
class _LyricsAdjustButton extends HookConsumerWidget {
|
||||||
final String trackPath;
|
final String trackPath;
|
||||||
|
final Player player;
|
||||||
|
|
||||||
const _LyricsRefreshButton({required this.trackPath});
|
const _LyricsAdjustButton({required this.trackPath, required this.player});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -750,9 +842,9 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
final neteaseProviderInstance = ref.watch(neteaseProvider);
|
final neteaseProviderInstance = ref.watch(neteaseProvider);
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.settings_applications),
|
||||||
iconSize: 24,
|
iconSize: 24,
|
||||||
tooltip: 'Refresh Lyrics',
|
tooltip: 'Adjust Lyrics',
|
||||||
onPressed: () => _showLyricsRefreshDialog(
|
onPressed: () => _showLyricsRefreshDialog(
|
||||||
context,
|
context,
|
||||||
ref,
|
ref,
|
||||||
@@ -774,63 +866,15 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
musixmatchProvider,
|
musixmatchProvider,
|
||||||
neteaseProvider,
|
neteaseProvider,
|
||||||
) {
|
) {
|
||||||
final metadata = metadataObj as TrackMetadata?;
|
|
||||||
final searchTerm =
|
|
||||||
'${metadata?.title ?? track.title} ${metadata?.artist ?? track.artist}'
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => _FetchLyricsDialog(
|
||||||
title: const Text('Fetch Lyrics'),
|
track: track,
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('Search term: $searchTerm'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('Choose a provider:'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_ProviderButton(
|
|
||||||
name: 'Musixmatch',
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await ref
|
|
||||||
.read(lyricsFetcherProvider.notifier)
|
|
||||||
.fetchLyricsForTrack(
|
|
||||||
trackId: track.id,
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
provider: musixmatchProvider,
|
|
||||||
trackPath: trackPath,
|
trackPath: trackPath,
|
||||||
);
|
metadataObj: metadataObj,
|
||||||
},
|
ref: ref,
|
||||||
),
|
musixmatchProvider: musixmatchProvider,
|
||||||
const SizedBox(width: 8),
|
neteaseProvider: neteaseProvider,
|
||||||
_ProviderButton(
|
|
||||||
name: 'NetEase',
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
await ref
|
|
||||||
.read(lyricsFetcherProvider.notifier)
|
|
||||||
.fetchLyricsForTrack(
|
|
||||||
trackId: track.id,
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
provider: neteaseProvider,
|
|
||||||
trackPath: trackPath,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -848,13 +892,14 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Lyrics Options'),
|
title: const Text('Lyrics Options'),
|
||||||
content: Column(
|
content: Column(
|
||||||
|
spacing: 8,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text('Choose an action:'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Column(
|
Column(
|
||||||
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
@@ -880,7 +925,6 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
@@ -920,12 +964,33 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
label: const Text('Live Sync Lyrics'),
|
||||||
|
onPressed: trackAsync.maybeWhen(
|
||||||
|
data: (track) => track != null
|
||||||
|
? () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showLiveLyricsSyncDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
track,
|
||||||
|
trackPath,
|
||||||
|
player,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
orElse: () => null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
label: const Text('Adjust Timing'),
|
label: const Text('Manual Offset'),
|
||||||
onPressed: trackAsync.maybeWhen(
|
onPressed: trackAsync.maybeWhen(
|
||||||
data: (track) => track != null
|
data: (track) => track != null
|
||||||
? () {
|
? () {
|
||||||
@@ -979,10 +1044,7 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
controller: offsetController,
|
controller: offsetController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(labelText: 'Offset (ms)'),
|
||||||
labelText: 'Offset (ms)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1011,6 +1073,24 @@ class _LyricsRefreshButton extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showLiveLyricsSyncDialog(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
db.Track track,
|
||||||
|
String trackPath,
|
||||||
|
Player player,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
useSafeArea: false,
|
||||||
|
builder: (dialogContext) => _LiveLyricsSyncDialog(
|
||||||
|
track: track,
|
||||||
|
trackPath: trackPath,
|
||||||
|
player: player,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ViewToggleButton extends StatelessWidget {
|
class _ViewToggleButton extends StatelessWidget {
|
||||||
@@ -1158,7 +1238,7 @@ class _QueueView extends HookConsumerWidget {
|
|||||||
size: 20,
|
size: 20,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurface.withOpacity(0.5),
|
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -1273,6 +1353,8 @@ class _TimedLyricsView extends HookConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final totalDurationMs = player.state.duration.inMilliseconds;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return ListWheelScrollView.useDelegate(
|
return ListWheelScrollView.useDelegate(
|
||||||
controller: wheelScrollController,
|
controller: wheelScrollController,
|
||||||
@@ -1288,6 +1370,20 @@ class _TimedLyricsView extends HookConsumerWidget {
|
|||||||
final line = lyrics.lines[index];
|
final line = lyrics.lines[index];
|
||||||
final isActive = index == currentIndex;
|
final isActive = index == currentIndex;
|
||||||
|
|
||||||
|
// Calculate progress within the current line for fill effect
|
||||||
|
double progress = 0.0;
|
||||||
|
if (isActive) {
|
||||||
|
final startTime = line.timeMs ?? 0;
|
||||||
|
final endTime = index < lyrics.lines.length - 1
|
||||||
|
? (lyrics.lines[index + 1].timeMs ?? startTime)
|
||||||
|
: totalDurationMs;
|
||||||
|
if (endTime > startTime) {
|
||||||
|
progress =
|
||||||
|
((positionMs - startTime) / (endTime - startTime))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -1316,14 +1412,41 @@ class _TimedLyricsView extends HookConsumerWidget {
|
|||||||
: Theme.of(context)
|
: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface
|
.onSurface
|
||||||
.withOpacity(0.7),
|
.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
|
child: () {
|
||||||
|
final displayText = line.text;
|
||||||
|
|
||||||
|
return isActive &&
|
||||||
|
progress > 0.0 &&
|
||||||
|
progress < 1.0
|
||||||
|
? ShaderMask(
|
||||||
|
shaderCallback: (bounds) =>
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
],
|
||||||
|
stops: [progress, progress],
|
||||||
|
).createShader(bounds),
|
||||||
child: Text(
|
child: Text(
|
||||||
line.text,
|
displayText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
displayText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1346,6 +1469,20 @@ class _TimedLyricsView extends HookConsumerWidget {
|
|||||||
final line = lyrics.lines[index];
|
final line = lyrics.lines[index];
|
||||||
final isActive = index == currentIndex;
|
final isActive = index == currentIndex;
|
||||||
|
|
||||||
|
// Calculate progress within the current line for fill effect
|
||||||
|
double progress = 0.0;
|
||||||
|
if (isActive) {
|
||||||
|
final startTime = line.timeMs ?? 0;
|
||||||
|
final endTime = index < lyrics.lines.length - 1
|
||||||
|
? (lyrics.lines[index + 1].timeMs ?? startTime)
|
||||||
|
: totalDurationMs;
|
||||||
|
if (endTime > startTime) {
|
||||||
|
progress =
|
||||||
|
((positionMs - startTime) / (endTime - startTime))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (line.timeMs != null) {
|
if (line.timeMs != null) {
|
||||||
@@ -1368,10 +1505,26 @@ class _TimedLyricsView extends HookConsumerWidget {
|
|||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: Theme.of(
|
: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurface.withOpacity(0.7),
|
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
child: Text(line.text),
|
child: () {
|
||||||
|
final displayText = line.text;
|
||||||
|
|
||||||
|
return isActive && progress > 0.0 && progress < 1.0
|
||||||
|
? ShaderMask(
|
||||||
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
Theme.of(context).colorScheme.onSurface
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
],
|
||||||
|
stops: [progress, progress],
|
||||||
|
).createShader(bounds),
|
||||||
|
child: Text(displayText),
|
||||||
|
)
|
||||||
|
: Text(displayText);
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1388,11 +1541,13 @@ class _PlayerControls extends HookWidget {
|
|||||||
final Player player;
|
final Player player;
|
||||||
final AsyncValue<TrackMetadata> metadataAsync;
|
final AsyncValue<TrackMetadata> metadataAsync;
|
||||||
final Media media;
|
final Media media;
|
||||||
|
final String trackPath;
|
||||||
|
|
||||||
const _PlayerControls({
|
const _PlayerControls({
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.metadataAsync,
|
required this.metadataAsync,
|
||||||
required this.media,
|
required this.media,
|
||||||
|
required this.trackPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1604,6 +1759,11 @@ class _PlayerControls extends HookWidget {
|
|||||||
// Volume Slider
|
// Volume Slider
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||||
|
child: Row(
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
_LyricsAdjustButton(trackPath: trackPath, player: player),
|
||||||
|
Expanded(
|
||||||
child: StreamBuilder<double>(
|
child: StreamBuilder<double>(
|
||||||
stream: player.stream.volume,
|
stream: player.stream.volume,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -1636,6 +1796,9 @@ class _PlayerControls extends HookWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,3 +1819,363 @@ final trackByPathProvider = FutureProvider.family<db.Track?, String>((
|
|||||||
database.tracks,
|
database.tracks,
|
||||||
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
|
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dialog for live lyrics synchronization
|
||||||
|
class _LiveLyricsSyncDialog extends HookConsumerWidget {
|
||||||
|
final db.Track track;
|
||||||
|
final String trackPath;
|
||||||
|
final Player player;
|
||||||
|
|
||||||
|
const _LiveLyricsSyncDialog({
|
||||||
|
required this.track,
|
||||||
|
required this.trackPath,
|
||||||
|
required this.player,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final tempOffset = useState(track.lyricsOffset);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Live Lyrics Sync'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
onPressed: () async {
|
||||||
|
// Store context before async operation
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
|
||||||
|
// Save the adjusted offset
|
||||||
|
final database = ref.read(databaseProvider);
|
||||||
|
await (database.update(
|
||||||
|
database.tracks,
|
||||||
|
)..where((t) => t.id.equals(track.id))).write(
|
||||||
|
db.TracksCompanion(lyricsOffset: drift.Value(tempOffset.value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate the track provider to refresh the UI
|
||||||
|
ref.invalidate(trackByPathProvider(trackPath));
|
||||||
|
|
||||||
|
navigator.pop();
|
||||||
|
},
|
||||||
|
tooltip: 'Save',
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: math.max(480, MediaQuery.sizeOf(context).width * 0.4),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Current offset display
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('Offset: '),
|
||||||
|
Text(
|
||||||
|
'${tempOffset.value}ms',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Offset adjustment buttons
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
runAlignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.fast_rewind),
|
||||||
|
label: const Text('-100ms'),
|
||||||
|
onPressed: () =>
|
||||||
|
tempOffset.value = (tempOffset.value - 100),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.skip_previous),
|
||||||
|
label: const Text('-10ms'),
|
||||||
|
onPressed: () =>
|
||||||
|
tempOffset.value = (tempOffset.value - 10),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Reset'),
|
||||||
|
onPressed: () => tempOffset.value = 0,
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.skip_next),
|
||||||
|
label: const Text('+10ms'),
|
||||||
|
onPressed: () =>
|
||||||
|
tempOffset.value = (tempOffset.value + 10),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.fast_forward),
|
||||||
|
label: const Text('+100ms'),
|
||||||
|
onPressed: () =>
|
||||||
|
tempOffset.value = (tempOffset.value + 100),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fine adjustment slider
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text('Fine Adjustment'),
|
||||||
|
Slider(
|
||||||
|
value: tempOffset.value.toDouble().clamp(-5000.0, 5000.0),
|
||||||
|
min: -5000,
|
||||||
|
max: 5000,
|
||||||
|
divisions: 100,
|
||||||
|
label: '${tempOffset.value}ms',
|
||||||
|
onChanged: (value) => tempOffset.value = value.toInt(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player controls
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_previous, size: 32),
|
||||||
|
onPressed: player.previous,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
StreamBuilder<bool>(
|
||||||
|
stream: player.stream.playing,
|
||||||
|
initialData: player.state.playing,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final playing = snapshot.data ?? false;
|
||||||
|
return IconButton.filled(
|
||||||
|
icon: Icon(
|
||||||
|
playing ? Icons.pause : Icons.play_arrow,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
onPressed: playing ? player.pause : player.play,
|
||||||
|
iconSize: 48,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.skip_next, size: 32),
|
||||||
|
onPressed: player.next,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: StreamBuilder<Duration>(
|
||||||
|
stream: player.stream.position,
|
||||||
|
initialData: player.state.position,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final position = snapshot.data ?? Duration.zero;
|
||||||
|
return StreamBuilder<Duration>(
|
||||||
|
stream: player.stream.duration,
|
||||||
|
initialData: player.state.duration,
|
||||||
|
builder: (context, durationSnapshot) {
|
||||||
|
final totalDuration =
|
||||||
|
durationSnapshot.data ?? Duration.zero;
|
||||||
|
final max = totalDuration.inMilliseconds.toDouble();
|
||||||
|
final positionValue = position.inMilliseconds
|
||||||
|
.toDouble()
|
||||||
|
.clamp(0.0, max > 0 ? max : 0.0);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Slider(
|
||||||
|
value: positionValue,
|
||||||
|
min: 0,
|
||||||
|
max: max > 0 ? max : 1.0,
|
||||||
|
onChanged: (val) => player.seek(
|
||||||
|
Duration(milliseconds: val.toInt()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDuration(
|
||||||
|
Duration(
|
||||||
|
milliseconds: positionValue.toInt(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(_formatDuration(totalDuration)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Lyrics preview with live offset
|
||||||
|
Expanded(
|
||||||
|
child: _LiveLyricsPreview(
|
||||||
|
track: track,
|
||||||
|
player: player,
|
||||||
|
tempOffset: tempOffset.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
final minutes = d.inMinutes;
|
||||||
|
final seconds = d.inSeconds % 60;
|
||||||
|
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widget for live lyrics preview with temporary offset
|
||||||
|
class _LiveLyricsPreview extends HookConsumerWidget {
|
||||||
|
final db.Track track;
|
||||||
|
final Player player;
|
||||||
|
final int tempOffset;
|
||||||
|
|
||||||
|
const _LiveLyricsPreview({
|
||||||
|
required this.track,
|
||||||
|
required this.player,
|
||||||
|
required this.tempOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
try {
|
||||||
|
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||||
|
|
||||||
|
if (lyricsData.type != 'timed') {
|
||||||
|
return const Center(child: Text('Only timed lyrics can be synced'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return StreamBuilder<Duration>(
|
||||||
|
stream: player.stream.position,
|
||||||
|
initialData: player.state.position,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final position = snapshot.data ?? Duration.zero;
|
||||||
|
final positionMs = position.inMilliseconds + tempOffset;
|
||||||
|
|
||||||
|
// Find current line index
|
||||||
|
int currentIndex = 0;
|
||||||
|
for (int i = 0; i < lyricsData.lines.length; i++) {
|
||||||
|
if ((lyricsData.lines[i].timeMs ?? 0) <= positionMs) {
|
||||||
|
currentIndex = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: lyricsData.lines.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final line = lyricsData.lines[index];
|
||||||
|
final isActive = index == currentIndex;
|
||||||
|
|
||||||
|
// Calculate progress within the current line for fill effect
|
||||||
|
double progress = 0.0;
|
||||||
|
if (isActive) {
|
||||||
|
final startTime = line.timeMs ?? 0;
|
||||||
|
final endTime = index < lyricsData.lines.length - 1
|
||||||
|
? (lyricsData.lines[index + 1].timeMs ?? startTime)
|
||||||
|
: player.state.duration.inMilliseconds;
|
||||||
|
if (endTime > startTime) {
|
||||||
|
progress = ((positionMs - startTime) / (endTime - startTime))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||||
|
fontSize: isActive ? 18 : 16,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isActive
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
child: () {
|
||||||
|
final displayText = kDebugMode
|
||||||
|
? '[${_formatTimestamp(line.timeMs ?? 0)}] ${line.text}'
|
||||||
|
: line.text;
|
||||||
|
|
||||||
|
return isActive && progress > 0.0 && progress < 1.0
|
||||||
|
? ShaderMask(
|
||||||
|
shaderCallback: (bounds) => LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
],
|
||||||
|
stops: [progress, progress],
|
||||||
|
).createShader(bounds),
|
||||||
|
child: Text(displayText),
|
||||||
|
)
|
||||||
|
: Text(displayText);
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return Center(child: Text('Error loading lyrics: $e'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format milliseconds as timestamp
|
||||||
|
String _formatTimestamp(int milliseconds) {
|
||||||
|
final duration = Duration(milliseconds: milliseconds);
|
||||||
|
final minutes = duration.inMinutes;
|
||||||
|
final seconds = duration.inSeconds % 60;
|
||||||
|
final millisecondsPart =
|
||||||
|
(duration.inMilliseconds % 1000) ~/ 10; // Show centiseconds
|
||||||
|
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}.${millisecondsPart.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user