diff --git a/lib/data/db.dart b/lib/data/db.dart index 7abb420..3867e1d 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -12,6 +12,9 @@ class Tracks extends Table { TextColumn get path => text().unique()(); TextColumn get artUri => text().nullable()(); // Path to local cover art TextColumn get lyrics => text().nullable()(); // JSON formatted lyrics + IntColumn get lyricsOffset => integer().withDefault( + const Constant(0), + )(); // Offset in milliseconds for lyrics timing DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)(); } @@ -34,7 +37,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 4; // Bump version for lyrics column + int get schemaVersion => 5; // Bump version for lyricsOffset column @override MigrationStrategy get migration { @@ -53,6 +56,9 @@ class AppDatabase extends _$AppDatabase { if (from < 4) { await m.addColumn(tracks, tracks.lyrics); } + if (from < 5) { + await m.addColumn(tracks, tracks.lyricsOffset); + } }, ); } diff --git a/lib/data/db.g.dart b/lib/data/db.g.dart index 857481b..92ef4e8 100644 --- a/lib/data/db.g.dart +++ b/lib/data/db.g.dart @@ -87,6 +87,18 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> { type: DriftSqlType.string, requiredDuringInsert: false, ); + static const VerificationMeta _lyricsOffsetMeta = const VerificationMeta( + 'lyricsOffset', + ); + @override + late final GeneratedColumn lyricsOffset = GeneratedColumn( + 'lyrics_offset', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); static const VerificationMeta _addedAtMeta = const VerificationMeta( 'addedAt', ); @@ -109,6 +121,7 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> { path, artUri, lyrics, + lyricsOffset, addedAt, ]; @override @@ -172,6 +185,15 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> { lyrics.isAcceptableOrUnknown(data['lyrics']!, _lyricsMeta), ); } + if (data.containsKey('lyrics_offset')) { + context.handle( + _lyricsOffsetMeta, + lyricsOffset.isAcceptableOrUnknown( + data['lyrics_offset']!, + _lyricsOffsetMeta, + ), + ); + } if (data.containsKey('added_at')) { context.handle( _addedAtMeta, @@ -219,6 +241,10 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> { DriftSqlType.string, data['${effectivePrefix}lyrics'], ), + lyricsOffset: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}lyrics_offset'], + )!, addedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}added_at'], @@ -241,6 +267,7 @@ class Track extends DataClass implements Insertable { final String path; final String? artUri; final String? lyrics; + final int lyricsOffset; final DateTime addedAt; const Track({ required this.id, @@ -251,6 +278,7 @@ class Track extends DataClass implements Insertable { required this.path, this.artUri, this.lyrics, + required this.lyricsOffset, required this.addedAt, }); @override @@ -274,6 +302,7 @@ class Track extends DataClass implements Insertable { if (!nullToAbsent || lyrics != null) { map['lyrics'] = Variable(lyrics); } + map['lyrics_offset'] = Variable(lyricsOffset); map['added_at'] = Variable(addedAt); return map; } @@ -298,6 +327,7 @@ class Track extends DataClass implements Insertable { lyrics: lyrics == null && nullToAbsent ? const Value.absent() : Value(lyrics), + lyricsOffset: Value(lyricsOffset), addedAt: Value(addedAt), ); } @@ -316,6 +346,7 @@ class Track extends DataClass implements Insertable { path: serializer.fromJson(json['path']), artUri: serializer.fromJson(json['artUri']), lyrics: serializer.fromJson(json['lyrics']), + lyricsOffset: serializer.fromJson(json['lyricsOffset']), addedAt: serializer.fromJson(json['addedAt']), ); } @@ -331,6 +362,7 @@ class Track extends DataClass implements Insertable { 'path': serializer.toJson(path), 'artUri': serializer.toJson(artUri), 'lyrics': serializer.toJson(lyrics), + 'lyricsOffset': serializer.toJson(lyricsOffset), 'addedAt': serializer.toJson(addedAt), }; } @@ -344,6 +376,7 @@ class Track extends DataClass implements Insertable { String? path, Value artUri = const Value.absent(), Value lyrics = const Value.absent(), + int? lyricsOffset, DateTime? addedAt, }) => Track( id: id ?? this.id, @@ -354,6 +387,7 @@ class Track extends DataClass implements Insertable { path: path ?? this.path, artUri: artUri.present ? artUri.value : this.artUri, lyrics: lyrics.present ? lyrics.value : this.lyrics, + lyricsOffset: lyricsOffset ?? this.lyricsOffset, addedAt: addedAt ?? this.addedAt, ); Track copyWithCompanion(TracksCompanion data) { @@ -366,6 +400,9 @@ class Track extends DataClass implements Insertable { path: data.path.present ? data.path.value : this.path, artUri: data.artUri.present ? data.artUri.value : this.artUri, lyrics: data.lyrics.present ? data.lyrics.value : this.lyrics, + lyricsOffset: data.lyricsOffset.present + ? data.lyricsOffset.value + : this.lyricsOffset, addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt, ); } @@ -381,6 +418,7 @@ class Track extends DataClass implements Insertable { ..write('path: $path, ') ..write('artUri: $artUri, ') ..write('lyrics: $lyrics, ') + ..write('lyricsOffset: $lyricsOffset, ') ..write('addedAt: $addedAt') ..write(')')) .toString(); @@ -396,6 +434,7 @@ class Track extends DataClass implements Insertable { path, artUri, lyrics, + lyricsOffset, addedAt, ); @override @@ -410,6 +449,7 @@ class Track extends DataClass implements Insertable { other.path == this.path && other.artUri == this.artUri && other.lyrics == this.lyrics && + other.lyricsOffset == this.lyricsOffset && other.addedAt == this.addedAt); } @@ -422,6 +462,7 @@ class TracksCompanion extends UpdateCompanion { final Value path; final Value artUri; final Value lyrics; + final Value lyricsOffset; final Value addedAt; const TracksCompanion({ this.id = const Value.absent(), @@ -432,6 +473,7 @@ class TracksCompanion extends UpdateCompanion { this.path = const Value.absent(), this.artUri = const Value.absent(), this.lyrics = const Value.absent(), + this.lyricsOffset = const Value.absent(), this.addedAt = const Value.absent(), }); TracksCompanion.insert({ @@ -443,6 +485,7 @@ class TracksCompanion extends UpdateCompanion { required String path, this.artUri = const Value.absent(), this.lyrics = const Value.absent(), + this.lyricsOffset = const Value.absent(), this.addedAt = const Value.absent(), }) : title = Value(title), path = Value(path); @@ -455,6 +498,7 @@ class TracksCompanion extends UpdateCompanion { Expression? path, Expression? artUri, Expression? lyrics, + Expression? lyricsOffset, Expression? addedAt, }) { return RawValuesInsertable({ @@ -466,6 +510,7 @@ class TracksCompanion extends UpdateCompanion { if (path != null) 'path': path, if (artUri != null) 'art_uri': artUri, if (lyrics != null) 'lyrics': lyrics, + if (lyricsOffset != null) 'lyrics_offset': lyricsOffset, if (addedAt != null) 'added_at': addedAt, }); } @@ -479,6 +524,7 @@ class TracksCompanion extends UpdateCompanion { Value? path, Value? artUri, Value? lyrics, + Value? lyricsOffset, Value? addedAt, }) { return TracksCompanion( @@ -490,6 +536,7 @@ class TracksCompanion extends UpdateCompanion { path: path ?? this.path, artUri: artUri ?? this.artUri, lyrics: lyrics ?? this.lyrics, + lyricsOffset: lyricsOffset ?? this.lyricsOffset, addedAt: addedAt ?? this.addedAt, ); } @@ -521,6 +568,9 @@ class TracksCompanion extends UpdateCompanion { if (lyrics.present) { map['lyrics'] = Variable(lyrics.value); } + if (lyricsOffset.present) { + map['lyrics_offset'] = Variable(lyricsOffset.value); + } if (addedAt.present) { map['added_at'] = Variable(addedAt.value); } @@ -538,6 +588,7 @@ class TracksCompanion extends UpdateCompanion { ..write('path: $path, ') ..write('artUri: $artUri, ') ..write('lyrics: $lyrics, ') + ..write('lyricsOffset: $lyricsOffset, ') ..write('addedAt: $addedAt') ..write(')')) .toString(); @@ -1135,6 +1186,7 @@ typedef $$TracksTableCreateCompanionBuilder = required String path, Value artUri, Value lyrics, + Value lyricsOffset, Value addedAt, }); typedef $$TracksTableUpdateCompanionBuilder = @@ -1147,6 +1199,7 @@ typedef $$TracksTableUpdateCompanionBuilder = Value path, Value artUri, Value lyrics, + Value lyricsOffset, Value addedAt, }); @@ -1224,6 +1277,11 @@ class $$TracksTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get lyricsOffset => $composableBuilder( + column: $table.lyricsOffset, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get addedAt => $composableBuilder( column: $table.addedAt, builder: (column) => ColumnFilters(column), @@ -1304,6 +1362,11 @@ class $$TracksTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get lyricsOffset => $composableBuilder( + column: $table.lyricsOffset, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get addedAt => $composableBuilder( column: $table.addedAt, builder: (column) => ColumnOrderings(column), @@ -1343,6 +1406,11 @@ class $$TracksTableAnnotationComposer GeneratedColumn get lyrics => $composableBuilder(column: $table.lyrics, builder: (column) => column); + GeneratedColumn get lyricsOffset => $composableBuilder( + column: $table.lyricsOffset, + builder: (column) => column, + ); + GeneratedColumn get addedAt => $composableBuilder(column: $table.addedAt, builder: (column) => column); @@ -1408,6 +1476,7 @@ class $$TracksTableTableManager Value path = const Value.absent(), Value artUri = const Value.absent(), Value lyrics = const Value.absent(), + Value lyricsOffset = const Value.absent(), Value addedAt = const Value.absent(), }) => TracksCompanion( id: id, @@ -1418,6 +1487,7 @@ class $$TracksTableTableManager path: path, artUri: artUri, lyrics: lyrics, + lyricsOffset: lyricsOffset, addedAt: addedAt, ), createCompanionCallback: @@ -1430,6 +1500,7 @@ class $$TracksTableTableManager required String path, Value artUri = const Value.absent(), Value lyrics = const Value.absent(), + Value lyricsOffset = const Value.absent(), Value addedAt = const Value.absent(), }) => TracksCompanion.insert( id: id, @@ -1440,6 +1511,7 @@ class $$TracksTableTableManager path: path, artUri: artUri, lyrics: lyrics, + lyricsOffset: lyricsOffset, addedAt: addedAt, ), withReferenceMapper: (p0) => p0 diff --git a/lib/data/track_repository.g.dart b/lib/data/track_repository.g.dart index 162a651..debb9ca 100644 --- a/lib/data/track_repository.g.dart +++ b/lib/data/track_repository.g.dart @@ -33,7 +33,7 @@ final class TrackRepositoryProvider TrackRepository create() => TrackRepository(); } -String _$trackRepositoryHash() => r'ad77006c472739d9d5067d394d6c5a3437535a11'; +String _$trackRepositoryHash() => r'244e5fc82fcaa34cb1276a41e4158a0eefcc7258'; abstract class _$TrackRepository extends $AsyncNotifier { FutureOr build(); diff --git a/lib/providers/lrc_fetcher_provider.g.dart b/lib/providers/lrc_fetcher_provider.g.dart index b2b2454..a37f203 100644 --- a/lib/providers/lrc_fetcher_provider.g.dart +++ b/lib/providers/lrc_fetcher_provider.g.dart @@ -41,7 +41,7 @@ final class LyricsFetcherProvider } } -String _$lyricsFetcherHash() => r'52296b2ccb55755ec5ad7ab751fe974dc3c64024'; +String _$lyricsFetcherHash() => r'071b83cb569812a6f90d42d7b7cf6954ac9631d7'; abstract class _$LyricsFetcher extends $Notifier { LyricsFetcherState build(); diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index 68fa655..5867bcb 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -372,7 +372,11 @@ class _PlayerLyrics extends HookConsumerWidget { if (lyricsData.type == 'timed') { return Stack( children: [ - _TimedLyricsView(lyrics: lyricsData, player: player), + _TimedLyricsView( + lyrics: lyricsData, + player: player, + trackPath: trackPath!, + ), _LyricsRefreshButton(trackPath: trackPath!), ], ); @@ -603,67 +607,95 @@ class _LyricsRefreshButton extends HookConsumerWidget { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Refresh Lyrics'), + title: const Text('Lyrics Options'), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text('Choose an action:'), const SizedBox(height: 16), - Row( + Column( children: [ - Expanded( + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Re-fetch'), + onPressed: trackAsync.maybeWhen( + data: (track) => track != null + ? () { + Navigator.of(context).pop(); + final metadata = metadataAsync.value; + _showFetchLyricsDialog( + context, + ref, + track, + trackPath, + metadata, + musixmatchProvider, + neteaseProvider, + ); + } + : null, + orElse: () => null, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.clear), + label: const Text('Clear'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: trackAsync.maybeWhen( + data: (track) => track != null + ? () async { + Navigator.of(context).pop(); + debugPrint( + 'Clearing lyrics for track ${track.id}', + ); + final database = ref.read(databaseProvider); + await (database.update( + database.tracks, + )..where((t) => t.id.equals(track.id))).write( + db.TracksCompanion( + lyrics: const drift.Value.absent(), + ), + ); + debugPrint('Cleared lyrics from database'); + // Invalidate the track provider to refresh the UI + ref.invalidate( + trackByPathProvider(trackPath), + ); + debugPrint( + 'Invalidated track provider for $trackPath', + ); + } + : null, + orElse: () => null, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, child: ElevatedButton.icon( - icon: const Icon(Icons.refresh), - label: const Text('Re-fetch'), + icon: const Icon(Icons.tune), + label: const Text('Adjust Timing'), onPressed: trackAsync.maybeWhen( data: (track) => track != null ? () { Navigator.of(context).pop(); - final metadata = metadataAsync.value; - _showFetchLyricsDialog( + _showLyricsOffsetDialog( context, ref, track, trackPath, - metadata, - musixmatchProvider, - neteaseProvider, - ); - } - : null, - orElse: () => null, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.clear), - label: const Text('Clear'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - ), - onPressed: trackAsync.maybeWhen( - data: (track) => track != null - ? () async { - Navigator.of(context).pop(); - debugPrint( - 'Clearing lyrics for track ${track.id}', - ); - final database = ref.read(databaseProvider); - await (database.update( - database.tracks, - )..where((t) => t.id.equals(track.id))).write( - db.TracksCompanion( - lyrics: const drift.Value.absent(), - ), - ); - debugPrint('Cleared lyrics from database'); - // Invalidate the track provider to refresh the UI - ref.invalidate(trackByPathProvider(trackPath)); - debugPrint( - 'Invalidated track provider for $trackPath', ); } : null, @@ -684,27 +716,77 @@ class _LyricsRefreshButton extends HookConsumerWidget { ), ); } + + void _showLyricsOffsetDialog( + BuildContext context, + WidgetRef ref, + db.Track track, + String trackPath, + ) { + final offsetController = TextEditingController( + text: track.lyricsOffset.toString(), + ); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Adjust Lyrics Timing'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Enter offset in milliseconds.\nPositive values delay lyrics, negative values advance them.', + ), + const SizedBox(height: 16), + TextField( + controller: offsetController, + decoration: const InputDecoration( + labelText: 'Offset (ms)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + final offset = int.tryParse(offsetController.text) ?? 0; + Navigator.of(context).pop(); + + final database = ref.read(databaseProvider); + await (database.update(database.tracks) + ..where((t) => t.id.equals(track.id))) + .write(db.TracksCompanion(lyricsOffset: drift.Value(offset))); + + // Invalidate the track provider to refresh the UI + ref.invalidate(trackByPathProvider(trackPath)); + }, + child: const Text('Save'), + ), + ], + ), + ); + } } -// Provider to fetch a single track by path -final trackByPathProvider = FutureProvider.family(( - ref, - trackPath, -) async { - final database = ref.watch(databaseProvider); - return (database.select( - database.tracks, - )..where((t) => t.path.equals(trackPath))).getSingleOrNull(); -}); - -class _TimedLyricsView extends HookWidget { +class _TimedLyricsView extends HookConsumerWidget { final LyricsData lyrics; final Player player; + final String trackPath; - const _TimedLyricsView({required this.lyrics, required this.player}); + const _TimedLyricsView({ + required this.lyrics, + required this.player, + required this.trackPath, + }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isDesktop = MediaQuery.sizeOf(context).width > 640; final listController = useMemoized(() => ListController(), []); @@ -715,145 +797,159 @@ class _TimedLyricsView extends HookWidget { ); final previousIndex = useState(-1); - return StreamBuilder( - stream: player.stream.position, - initialData: player.state.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final positionMs = position.inMilliseconds; + // Get track data to access lyrics offset + final trackAsync = ref.watch(trackByPathProvider(trackPath)); - // Find current line index - int currentIndex = 0; - for (int i = 0; i < lyrics.lines.length; i++) { - if ((lyrics.lines[i].timeMs ?? 0) <= positionMs) { - currentIndex = i; - } else { - break; - } - } + return trackAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (track) { + final lyricsOffset = track?.lyricsOffset ?? 0; - // Auto-scroll when current line changes - if (currentIndex != previousIndex.value) { - WidgetsBinding.instance.addPostFrameCallback((_) { - previousIndex.value = currentIndex; - if (isDesktop) { - if (wheelScrollController.hasClients) { - wheelScrollController.animateToItem( - currentIndex, - duration: const Duration(milliseconds: 400), - curve: Curves.easeOutCubic, - ); + return StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, snapshot) { + final position = snapshot.data ?? Duration.zero; + final positionMs = position.inMilliseconds + lyricsOffset; + + // Find current line index + int currentIndex = 0; + for (int i = 0; i < lyrics.lines.length; i++) { + if ((lyrics.lines[i].timeMs ?? 0) <= positionMs) { + currentIndex = i; + } else { + break; } - } else { - listController.animateToItem( - index: currentIndex, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => const Duration(milliseconds: 300), - curve: (_) => Curves.easeOutCubic, - ); } - }); - } - if (isDesktop) { - return ListWheelScrollView.useDelegate( - controller: wheelScrollController, - itemExtent: 50, - perspective: 0.002, - offAxisFraction: 1.5, - squeeze: 1.0, - diameterRatio: 2, - physics: const FixedExtentScrollPhysics(), - childDelegate: ListWheelChildBuilderDelegate( - childCount: lyrics.lines.length, - builder: (context, index) { - final line = lyrics.lines[index]; - final isActive = index == currentIndex; + // Auto-scroll when current line changes + if (currentIndex != previousIndex.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + previousIndex.value = currentIndex; + if (isDesktop) { + if (wheelScrollController.hasClients) { + wheelScrollController.animateToItem( + currentIndex, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + ); + } + } else { + listController.animateToItem( + index: currentIndex, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => const Duration(milliseconds: 300), + curve: (_) => Curves.easeOutCubic, + ); + } + }); + } - return Align( - alignment: Alignment.centerRight, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: MediaQuery.sizeOf(context).width * 0.4, - ), - child: InkWell( - onTap: () { - if (line.timeMs != null) { - player.seek(Duration(milliseconds: line.timeMs!)); - } - }, - child: Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 32), - 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.withOpacity(0.7), + if (isDesktop) { + return ListWheelScrollView.useDelegate( + controller: wheelScrollController, + itemExtent: 50, + perspective: 0.002, + offAxisFraction: 1.5, + squeeze: 1.0, + diameterRatio: 2, + physics: const FixedExtentScrollPhysics(), + childDelegate: ListWheelChildBuilderDelegate( + childCount: lyrics.lines.length, + builder: (context, index) { + final line = lyrics.lines[index]; + final isActive = index == currentIndex; + + return Align( + alignment: Alignment.centerRight, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.4, + ), + child: InkWell( + onTap: () { + if (line.timeMs != null) { + player.seek(Duration(milliseconds: line.timeMs!)); + } + }, + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 32), + 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 + .withOpacity(0.7), + ), + textAlign: TextAlign.left, + child: Text( + line.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.left, - child: Text( - line.text, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), ), ), ), + ); + }, + ), + ); + } + + return SuperListView.builder( + padding: EdgeInsets.only( + top: 0.25 * MediaQuery.sizeOf(context).height, + bottom: 0.25 * MediaQuery.sizeOf(context).height, + ), + listController: listController, + controller: scrollController, + itemCount: lyrics.lines.length, + itemBuilder: (context, index) { + final line = lyrics.lines[index]; + final isActive = index == currentIndex; + + return InkWell( + onTap: () { + if (line.timeMs != null) { + player.seek(Duration(milliseconds: line.timeMs!)); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontSize: isActive ? 20 : 16, + fontWeight: isActive + ? FontWeight.bold + : FontWeight.normal, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.center, + child: Text(line.text), ), ), ); }, - ), - ); - } - - return SuperListView.builder( - padding: EdgeInsets.only( - top: 0.25 * MediaQuery.sizeOf(context).height, - bottom: 0.25 * MediaQuery.sizeOf(context).height, - ), - listController: listController, - controller: scrollController, - itemCount: lyrics.lines.length, - itemBuilder: (context, index) { - final line = lyrics.lines[index]; - final isActive = index == currentIndex; - - return InkWell( - onTap: () { - if (line.timeMs != null) { - player.seek(Duration(milliseconds: line.timeMs!)); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), - style: Theme.of(context).textTheme.bodyLarge!.copyWith( - fontSize: isActive ? 20 : 16, - fontWeight: isActive ? FontWeight.bold : FontWeight.normal, - color: isActive - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.7), - ), - textAlign: TextAlign.center, - child: Text(line.text), - ), - ), ); }, ); @@ -954,11 +1050,11 @@ class _PlayerControls extends HookWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - _formatDuration( + formatDuration( Duration(milliseconds: currentValue.toInt()), ), ), - Text(_formatDuration(totalDuration)), + Text(formatDuration(totalDuration)), ], ), ), @@ -1117,9 +1213,20 @@ class _PlayerControls extends HookWidget { ); } - String _formatDuration(Duration d) { + String formatDuration(Duration d) { final minutes = d.inMinutes; final seconds = d.inSeconds % 60; return '$minutes:${seconds.toString().padLeft(2, '0')}'; } } + +// Provider to fetch a single track by path +final trackByPathProvider = FutureProvider.family(( + ref, + trackPath, +) async { + final database = ref.watch(databaseProvider); + return (database.select( + database.tracks, + )..where((t) => t.path.equals(trackPath))).getSingleOrNull(); +});