Lyrics offset

This commit is contained in:
2025-12-16 22:35:43 +08:00
parent ac78ac002b
commit da71c31e2a
5 changed files with 377 additions and 192 deletions

View File

@@ -12,6 +12,9 @@ class Tracks extends Table {
TextColumn get path => text().unique()(); TextColumn get path => text().unique()();
TextColumn get artUri => text().nullable()(); // Path to local cover art TextColumn get artUri => text().nullable()(); // Path to local cover art
TextColumn get lyrics => text().nullable()(); // JSON formatted lyrics 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)(); DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)();
} }
@@ -34,7 +37,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 4; // Bump version for lyrics column int get schemaVersion => 5; // Bump version for lyricsOffset column
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -53,6 +56,9 @@ class AppDatabase extends _$AppDatabase {
if (from < 4) { if (from < 4) {
await m.addColumn(tracks, tracks.lyrics); await m.addColumn(tracks, tracks.lyrics);
} }
if (from < 5) {
await m.addColumn(tracks, tracks.lyricsOffset);
}
}, },
); );
} }

View File

@@ -87,6 +87,18 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
type: DriftSqlType.string, type: DriftSqlType.string,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _lyricsOffsetMeta = const VerificationMeta(
'lyricsOffset',
);
@override
late final GeneratedColumn<int> lyricsOffset = GeneratedColumn<int>(
'lyrics_offset',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _addedAtMeta = const VerificationMeta( static const VerificationMeta _addedAtMeta = const VerificationMeta(
'addedAt', 'addedAt',
); );
@@ -109,6 +121,7 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
path, path,
artUri, artUri,
lyrics, lyrics,
lyricsOffset,
addedAt, addedAt,
]; ];
@override @override
@@ -172,6 +185,15 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
lyrics.isAcceptableOrUnknown(data['lyrics']!, _lyricsMeta), lyrics.isAcceptableOrUnknown(data['lyrics']!, _lyricsMeta),
); );
} }
if (data.containsKey('lyrics_offset')) {
context.handle(
_lyricsOffsetMeta,
lyricsOffset.isAcceptableOrUnknown(
data['lyrics_offset']!,
_lyricsOffsetMeta,
),
);
}
if (data.containsKey('added_at')) { if (data.containsKey('added_at')) {
context.handle( context.handle(
_addedAtMeta, _addedAtMeta,
@@ -219,6 +241,10 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}lyrics'], data['${effectivePrefix}lyrics'],
), ),
lyricsOffset: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}lyrics_offset'],
)!,
addedAt: attachedDatabase.typeMapping.read( addedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}added_at'], data['${effectivePrefix}added_at'],
@@ -241,6 +267,7 @@ class Track extends DataClass implements Insertable<Track> {
final String path; final String path;
final String? artUri; final String? artUri;
final String? lyrics; final String? lyrics;
final int lyricsOffset;
final DateTime addedAt; final DateTime addedAt;
const Track({ const Track({
required this.id, required this.id,
@@ -251,6 +278,7 @@ class Track extends DataClass implements Insertable<Track> {
required this.path, required this.path,
this.artUri, this.artUri,
this.lyrics, this.lyrics,
required this.lyricsOffset,
required this.addedAt, required this.addedAt,
}); });
@override @override
@@ -274,6 +302,7 @@ class Track extends DataClass implements Insertable<Track> {
if (!nullToAbsent || lyrics != null) { if (!nullToAbsent || lyrics != null) {
map['lyrics'] = Variable<String>(lyrics); map['lyrics'] = Variable<String>(lyrics);
} }
map['lyrics_offset'] = Variable<int>(lyricsOffset);
map['added_at'] = Variable<DateTime>(addedAt); map['added_at'] = Variable<DateTime>(addedAt);
return map; return map;
} }
@@ -298,6 +327,7 @@ class Track extends DataClass implements Insertable<Track> {
lyrics: lyrics == null && nullToAbsent lyrics: lyrics == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(lyrics), : Value(lyrics),
lyricsOffset: Value(lyricsOffset),
addedAt: Value(addedAt), addedAt: Value(addedAt),
); );
} }
@@ -316,6 +346,7 @@ class Track extends DataClass implements Insertable<Track> {
path: serializer.fromJson<String>(json['path']), path: serializer.fromJson<String>(json['path']),
artUri: serializer.fromJson<String?>(json['artUri']), artUri: serializer.fromJson<String?>(json['artUri']),
lyrics: serializer.fromJson<String?>(json['lyrics']), lyrics: serializer.fromJson<String?>(json['lyrics']),
lyricsOffset: serializer.fromJson<int>(json['lyricsOffset']),
addedAt: serializer.fromJson<DateTime>(json['addedAt']), addedAt: serializer.fromJson<DateTime>(json['addedAt']),
); );
} }
@@ -331,6 +362,7 @@ class Track extends DataClass implements Insertable<Track> {
'path': serializer.toJson<String>(path), 'path': serializer.toJson<String>(path),
'artUri': serializer.toJson<String?>(artUri), 'artUri': serializer.toJson<String?>(artUri),
'lyrics': serializer.toJson<String?>(lyrics), 'lyrics': serializer.toJson<String?>(lyrics),
'lyricsOffset': serializer.toJson<int>(lyricsOffset),
'addedAt': serializer.toJson<DateTime>(addedAt), 'addedAt': serializer.toJson<DateTime>(addedAt),
}; };
} }
@@ -344,6 +376,7 @@ class Track extends DataClass implements Insertable<Track> {
String? path, String? path,
Value<String?> artUri = const Value.absent(), Value<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(), Value<String?> lyrics = const Value.absent(),
int? lyricsOffset,
DateTime? addedAt, DateTime? addedAt,
}) => Track( }) => Track(
id: id ?? this.id, id: id ?? this.id,
@@ -354,6 +387,7 @@ class Track extends DataClass implements Insertable<Track> {
path: path ?? this.path, path: path ?? this.path,
artUri: artUri.present ? artUri.value : this.artUri, artUri: artUri.present ? artUri.value : this.artUri,
lyrics: lyrics.present ? lyrics.value : this.lyrics, lyrics: lyrics.present ? lyrics.value : this.lyrics,
lyricsOffset: lyricsOffset ?? this.lyricsOffset,
addedAt: addedAt ?? this.addedAt, addedAt: addedAt ?? this.addedAt,
); );
Track copyWithCompanion(TracksCompanion data) { Track copyWithCompanion(TracksCompanion data) {
@@ -366,6 +400,9 @@ class Track extends DataClass implements Insertable<Track> {
path: data.path.present ? data.path.value : this.path, path: data.path.present ? data.path.value : this.path,
artUri: data.artUri.present ? data.artUri.value : this.artUri, artUri: data.artUri.present ? data.artUri.value : this.artUri,
lyrics: data.lyrics.present ? data.lyrics.value : this.lyrics, 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, addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt,
); );
} }
@@ -381,6 +418,7 @@ class Track extends DataClass implements Insertable<Track> {
..write('path: $path, ') ..write('path: $path, ')
..write('artUri: $artUri, ') ..write('artUri: $artUri, ')
..write('lyrics: $lyrics, ') ..write('lyrics: $lyrics, ')
..write('lyricsOffset: $lyricsOffset, ')
..write('addedAt: $addedAt') ..write('addedAt: $addedAt')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -396,6 +434,7 @@ class Track extends DataClass implements Insertable<Track> {
path, path,
artUri, artUri,
lyrics, lyrics,
lyricsOffset,
addedAt, addedAt,
); );
@override @override
@@ -410,6 +449,7 @@ class Track extends DataClass implements Insertable<Track> {
other.path == this.path && other.path == this.path &&
other.artUri == this.artUri && other.artUri == this.artUri &&
other.lyrics == this.lyrics && other.lyrics == this.lyrics &&
other.lyricsOffset == this.lyricsOffset &&
other.addedAt == this.addedAt); other.addedAt == this.addedAt);
} }
@@ -422,6 +462,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
final Value<String> path; final Value<String> path;
final Value<String?> artUri; final Value<String?> artUri;
final Value<String?> lyrics; final Value<String?> lyrics;
final Value<int> lyricsOffset;
final Value<DateTime> addedAt; final Value<DateTime> addedAt;
const TracksCompanion({ const TracksCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
@@ -432,6 +473,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
this.path = const Value.absent(), this.path = const Value.absent(),
this.artUri = const Value.absent(), this.artUri = const Value.absent(),
this.lyrics = const Value.absent(), this.lyrics = const Value.absent(),
this.lyricsOffset = const Value.absent(),
this.addedAt = const Value.absent(), this.addedAt = const Value.absent(),
}); });
TracksCompanion.insert({ TracksCompanion.insert({
@@ -443,6 +485,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
required String path, required String path,
this.artUri = const Value.absent(), this.artUri = const Value.absent(),
this.lyrics = const Value.absent(), this.lyrics = const Value.absent(),
this.lyricsOffset = const Value.absent(),
this.addedAt = const Value.absent(), this.addedAt = const Value.absent(),
}) : title = Value(title), }) : title = Value(title),
path = Value(path); path = Value(path);
@@ -455,6 +498,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
Expression<String>? path, Expression<String>? path,
Expression<String>? artUri, Expression<String>? artUri,
Expression<String>? lyrics, Expression<String>? lyrics,
Expression<int>? lyricsOffset,
Expression<DateTime>? addedAt, Expression<DateTime>? addedAt,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
@@ -466,6 +510,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
if (path != null) 'path': path, if (path != null) 'path': path,
if (artUri != null) 'art_uri': artUri, if (artUri != null) 'art_uri': artUri,
if (lyrics != null) 'lyrics': lyrics, if (lyrics != null) 'lyrics': lyrics,
if (lyricsOffset != null) 'lyrics_offset': lyricsOffset,
if (addedAt != null) 'added_at': addedAt, if (addedAt != null) 'added_at': addedAt,
}); });
} }
@@ -479,6 +524,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
Value<String>? path, Value<String>? path,
Value<String?>? artUri, Value<String?>? artUri,
Value<String?>? lyrics, Value<String?>? lyrics,
Value<int>? lyricsOffset,
Value<DateTime>? addedAt, Value<DateTime>? addedAt,
}) { }) {
return TracksCompanion( return TracksCompanion(
@@ -490,6 +536,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
path: path ?? this.path, path: path ?? this.path,
artUri: artUri ?? this.artUri, artUri: artUri ?? this.artUri,
lyrics: lyrics ?? this.lyrics, lyrics: lyrics ?? this.lyrics,
lyricsOffset: lyricsOffset ?? this.lyricsOffset,
addedAt: addedAt ?? this.addedAt, addedAt: addedAt ?? this.addedAt,
); );
} }
@@ -521,6 +568,9 @@ class TracksCompanion extends UpdateCompanion<Track> {
if (lyrics.present) { if (lyrics.present) {
map['lyrics'] = Variable<String>(lyrics.value); map['lyrics'] = Variable<String>(lyrics.value);
} }
if (lyricsOffset.present) {
map['lyrics_offset'] = Variable<int>(lyricsOffset.value);
}
if (addedAt.present) { if (addedAt.present) {
map['added_at'] = Variable<DateTime>(addedAt.value); map['added_at'] = Variable<DateTime>(addedAt.value);
} }
@@ -538,6 +588,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
..write('path: $path, ') ..write('path: $path, ')
..write('artUri: $artUri, ') ..write('artUri: $artUri, ')
..write('lyrics: $lyrics, ') ..write('lyrics: $lyrics, ')
..write('lyricsOffset: $lyricsOffset, ')
..write('addedAt: $addedAt') ..write('addedAt: $addedAt')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -1135,6 +1186,7 @@ typedef $$TracksTableCreateCompanionBuilder =
required String path, required String path,
Value<String?> artUri, Value<String?> artUri,
Value<String?> lyrics, Value<String?> lyrics,
Value<int> lyricsOffset,
Value<DateTime> addedAt, Value<DateTime> addedAt,
}); });
typedef $$TracksTableUpdateCompanionBuilder = typedef $$TracksTableUpdateCompanionBuilder =
@@ -1147,6 +1199,7 @@ typedef $$TracksTableUpdateCompanionBuilder =
Value<String> path, Value<String> path,
Value<String?> artUri, Value<String?> artUri,
Value<String?> lyrics, Value<String?> lyrics,
Value<int> lyricsOffset,
Value<DateTime> addedAt, Value<DateTime> addedAt,
}); });
@@ -1224,6 +1277,11 @@ class $$TracksTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get addedAt => $composableBuilder( ColumnFilters<DateTime> get addedAt => $composableBuilder(
column: $table.addedAt, column: $table.addedAt,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@@ -1304,6 +1362,11 @@ class $$TracksTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get addedAt => $composableBuilder( ColumnOrderings<DateTime> get addedAt => $composableBuilder(
column: $table.addedAt, column: $table.addedAt,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@@ -1343,6 +1406,11 @@ class $$TracksTableAnnotationComposer
GeneratedColumn<String> get lyrics => GeneratedColumn<String> get lyrics =>
$composableBuilder(column: $table.lyrics, builder: (column) => column); $composableBuilder(column: $table.lyrics, builder: (column) => column);
GeneratedColumn<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => column,
);
GeneratedColumn<DateTime> get addedAt => GeneratedColumn<DateTime> get addedAt =>
$composableBuilder(column: $table.addedAt, builder: (column) => column); $composableBuilder(column: $table.addedAt, builder: (column) => column);
@@ -1408,6 +1476,7 @@ class $$TracksTableTableManager
Value<String> path = const Value.absent(), Value<String> path = const Value.absent(),
Value<String?> artUri = const Value.absent(), Value<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(), Value<String?> lyrics = const Value.absent(),
Value<int> lyricsOffset = const Value.absent(),
Value<DateTime> addedAt = const Value.absent(), Value<DateTime> addedAt = const Value.absent(),
}) => TracksCompanion( }) => TracksCompanion(
id: id, id: id,
@@ -1418,6 +1487,7 @@ class $$TracksTableTableManager
path: path, path: path,
artUri: artUri, artUri: artUri,
lyrics: lyrics, lyrics: lyrics,
lyricsOffset: lyricsOffset,
addedAt: addedAt, addedAt: addedAt,
), ),
createCompanionCallback: createCompanionCallback:
@@ -1430,6 +1500,7 @@ class $$TracksTableTableManager
required String path, required String path,
Value<String?> artUri = const Value.absent(), Value<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(), Value<String?> lyrics = const Value.absent(),
Value<int> lyricsOffset = const Value.absent(),
Value<DateTime> addedAt = const Value.absent(), Value<DateTime> addedAt = const Value.absent(),
}) => TracksCompanion.insert( }) => TracksCompanion.insert(
id: id, id: id,
@@ -1440,6 +1511,7 @@ class $$TracksTableTableManager
path: path, path: path,
artUri: artUri, artUri: artUri,
lyrics: lyrics, lyrics: lyrics,
lyricsOffset: lyricsOffset,
addedAt: addedAt, addedAt: addedAt,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0

View File

@@ -33,7 +33,7 @@ final class TrackRepositoryProvider
TrackRepository create() => TrackRepository(); TrackRepository create() => TrackRepository();
} }
String _$trackRepositoryHash() => r'ad77006c472739d9d5067d394d6c5a3437535a11'; String _$trackRepositoryHash() => r'244e5fc82fcaa34cb1276a41e4158a0eefcc7258';
abstract class _$TrackRepository extends $AsyncNotifier<void> { abstract class _$TrackRepository extends $AsyncNotifier<void> {
FutureOr<void> build(); FutureOr<void> build();

View File

@@ -41,7 +41,7 @@ final class LyricsFetcherProvider
} }
} }
String _$lyricsFetcherHash() => r'52296b2ccb55755ec5ad7ab751fe974dc3c64024'; String _$lyricsFetcherHash() => r'071b83cb569812a6f90d42d7b7cf6954ac9631d7';
abstract class _$LyricsFetcher extends $Notifier<LyricsFetcherState> { abstract class _$LyricsFetcher extends $Notifier<LyricsFetcherState> {
LyricsFetcherState build(); LyricsFetcherState build();

View File

@@ -372,7 +372,11 @@ class _PlayerLyrics extends HookConsumerWidget {
if (lyricsData.type == 'timed') { if (lyricsData.type == 'timed') {
return Stack( return Stack(
children: [ children: [
_TimedLyricsView(lyrics: lyricsData, player: player), _TimedLyricsView(
lyrics: lyricsData,
player: player,
trackPath: trackPath!,
),
_LyricsRefreshButton(trackPath: trackPath!), _LyricsRefreshButton(trackPath: trackPath!),
], ],
); );
@@ -603,67 +607,95 @@ class _LyricsRefreshButton extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Refresh Lyrics'), title: const Text('Lyrics Options'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Choose an action:'), const Text('Choose an action:'),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Column(
children: [ 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( child: ElevatedButton.icon(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.tune),
label: const Text('Re-fetch'), label: const Text('Adjust Timing'),
onPressed: trackAsync.maybeWhen( onPressed: trackAsync.maybeWhen(
data: (track) => track != null data: (track) => track != null
? () { ? () {
Navigator.of(context).pop(); Navigator.of(context).pop();
final metadata = metadataAsync.value; _showLyricsOffsetDialog(
_showFetchLyricsDialog(
context, context,
ref, ref,
track, track,
trackPath, 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, : 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 class _TimedLyricsView extends HookConsumerWidget {
final trackByPathProvider = FutureProvider.family<db.Track?, String>((
ref,
trackPath,
) async {
final database = ref.watch(databaseProvider);
return (database.select(
database.tracks,
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
});
class _TimedLyricsView extends HookWidget {
final LyricsData lyrics; final LyricsData lyrics;
final Player player; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isDesktop = MediaQuery.sizeOf(context).width > 640; final isDesktop = MediaQuery.sizeOf(context).width > 640;
final listController = useMemoized(() => ListController(), []); final listController = useMemoized(() => ListController(), []);
@@ -715,145 +797,159 @@ class _TimedLyricsView extends HookWidget {
); );
final previousIndex = useState(-1); final previousIndex = useState(-1);
return StreamBuilder<Duration>( // Get track data to access lyrics offset
stream: player.stream.position, final trackAsync = ref.watch(trackByPathProvider(trackPath));
initialData: player.state.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final positionMs = position.inMilliseconds;
// Find current line index return trackAsync.when(
int currentIndex = 0; loading: () => const Center(child: CircularProgressIndicator()),
for (int i = 0; i < lyrics.lines.length; i++) { error: (e, _) => Center(child: Text('Error: $e')),
if ((lyrics.lines[i].timeMs ?? 0) <= positionMs) { data: (track) {
currentIndex = i; final lyricsOffset = track?.lyricsOffset ?? 0;
} else {
break;
}
}
// Auto-scroll when current line changes return StreamBuilder<Duration>(
if (currentIndex != previousIndex.value) { stream: player.stream.position,
WidgetsBinding.instance.addPostFrameCallback((_) { initialData: player.state.position,
previousIndex.value = currentIndex; builder: (context, snapshot) {
if (isDesktop) { final position = snapshot.data ?? Duration.zero;
if (wheelScrollController.hasClients) { final positionMs = position.inMilliseconds + lyricsOffset;
wheelScrollController.animateToItem(
currentIndex, // Find current line index
duration: const Duration(milliseconds: 400), int currentIndex = 0;
curve: Curves.easeOutCubic, 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) { // Auto-scroll when current line changes
return ListWheelScrollView.useDelegate( if (currentIndex != previousIndex.value) {
controller: wheelScrollController, WidgetsBinding.instance.addPostFrameCallback((_) {
itemExtent: 50, previousIndex.value = currentIndex;
perspective: 0.002, if (isDesktop) {
offAxisFraction: 1.5, if (wheelScrollController.hasClients) {
squeeze: 1.0, wheelScrollController.animateToItem(
diameterRatio: 2, currentIndex,
physics: const FixedExtentScrollPhysics(), duration: const Duration(milliseconds: 400),
childDelegate: ListWheelChildBuilderDelegate( curve: Curves.easeOutCubic,
childCount: lyrics.lines.length, );
builder: (context, index) { }
final line = lyrics.lines[index]; } else {
final isActive = index == currentIndex; listController.animateToItem(
index: currentIndex,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => const Duration(milliseconds: 300),
curve: (_) => Curves.easeOutCubic,
);
}
});
}
return Align( if (isDesktop) {
alignment: Alignment.centerRight, return ListWheelScrollView.useDelegate(
child: ConstrainedBox( controller: wheelScrollController,
constraints: BoxConstraints( itemExtent: 50,
maxWidth: MediaQuery.sizeOf(context).width * 0.4, perspective: 0.002,
), offAxisFraction: 1.5,
child: InkWell( squeeze: 1.0,
onTap: () { diameterRatio: 2,
if (line.timeMs != null) { physics: const FixedExtentScrollPhysics(),
player.seek(Duration(milliseconds: line.timeMs!)); childDelegate: ListWheelChildBuilderDelegate(
} childCount: lyrics.lines.length,
}, builder: (context, index) {
child: Container( final line = lyrics.lines[index];
alignment: Alignment.centerLeft, final isActive = index == currentIndex;
padding: const EdgeInsets.symmetric(horizontal: 32),
child: AnimatedDefaultTextStyle( return Align(
duration: const Duration(milliseconds: 200), alignment: Alignment.centerRight,
style: Theme.of(context).textTheme.bodyLarge! child: ConstrainedBox(
.copyWith( constraints: BoxConstraints(
fontSize: isActive ? 18 : 16, maxWidth: MediaQuery.sizeOf(context).width * 0.4,
fontWeight: isActive ),
? FontWeight.bold child: InkWell(
: FontWeight.normal, onTap: () {
color: isActive if (line.timeMs != null) {
? Theme.of(context).colorScheme.primary player.seek(Duration(milliseconds: line.timeMs!));
: Theme.of( }
context, },
).colorScheme.onSurface.withOpacity(0.7), 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, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
_formatDuration( formatDuration(
Duration(milliseconds: currentValue.toInt()), 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 minutes = d.inMinutes;
final seconds = d.inSeconds % 60; final seconds = d.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}'; return '$minutes:${seconds.toString().padLeft(2, '0')}';
} }
} }
// Provider to fetch a single track by path
final trackByPathProvider = FutureProvider.family<db.Track?, String>((
ref,
trackPath,
) async {
final database = ref.watch(databaseProvider);
return (database.select(
database.tracks,
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
});