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 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);
}
},
);
}

View File

@@ -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<int> lyricsOffset = GeneratedColumn<int>(
'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<Track> {
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<Track> {
required this.path,
this.artUri,
this.lyrics,
required this.lyricsOffset,
required this.addedAt,
});
@override
@@ -274,6 +302,7 @@ class Track extends DataClass implements Insertable<Track> {
if (!nullToAbsent || lyrics != null) {
map['lyrics'] = Variable<String>(lyrics);
}
map['lyrics_offset'] = Variable<int>(lyricsOffset);
map['added_at'] = Variable<DateTime>(addedAt);
return map;
}
@@ -298,6 +327,7 @@ class Track extends DataClass implements Insertable<Track> {
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<Track> {
path: serializer.fromJson<String>(json['path']),
artUri: serializer.fromJson<String?>(json['artUri']),
lyrics: serializer.fromJson<String?>(json['lyrics']),
lyricsOffset: serializer.fromJson<int>(json['lyricsOffset']),
addedAt: serializer.fromJson<DateTime>(json['addedAt']),
);
}
@@ -331,6 +362,7 @@ class Track extends DataClass implements Insertable<Track> {
'path': serializer.toJson<String>(path),
'artUri': serializer.toJson<String?>(artUri),
'lyrics': serializer.toJson<String?>(lyrics),
'lyricsOffset': serializer.toJson<int>(lyricsOffset),
'addedAt': serializer.toJson<DateTime>(addedAt),
};
}
@@ -344,6 +376,7 @@ class Track extends DataClass implements Insertable<Track> {
String? path,
Value<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(),
int? lyricsOffset,
DateTime? addedAt,
}) => Track(
id: id ?? this.id,
@@ -354,6 +387,7 @@ class Track extends DataClass implements Insertable<Track> {
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<Track> {
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<Track> {
..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<Track> {
path,
artUri,
lyrics,
lyricsOffset,
addedAt,
);
@override
@@ -410,6 +449,7 @@ class Track extends DataClass implements Insertable<Track> {
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<Track> {
final Value<String> path;
final Value<String?> artUri;
final Value<String?> lyrics;
final Value<int> lyricsOffset;
final Value<DateTime> addedAt;
const TracksCompanion({
this.id = const Value.absent(),
@@ -432,6 +473,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
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<Track> {
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<Track> {
Expression<String>? path,
Expression<String>? artUri,
Expression<String>? lyrics,
Expression<int>? lyricsOffset,
Expression<DateTime>? addedAt,
}) {
return RawValuesInsertable({
@@ -466,6 +510,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
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<Track> {
Value<String>? path,
Value<String?>? artUri,
Value<String?>? lyrics,
Value<int>? lyricsOffset,
Value<DateTime>? addedAt,
}) {
return TracksCompanion(
@@ -490,6 +536,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
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<Track> {
if (lyrics.present) {
map['lyrics'] = Variable<String>(lyrics.value);
}
if (lyricsOffset.present) {
map['lyrics_offset'] = Variable<int>(lyricsOffset.value);
}
if (addedAt.present) {
map['added_at'] = Variable<DateTime>(addedAt.value);
}
@@ -538,6 +588,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
..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<String?> artUri,
Value<String?> lyrics,
Value<int> lyricsOffset,
Value<DateTime> addedAt,
});
typedef $$TracksTableUpdateCompanionBuilder =
@@ -1147,6 +1199,7 @@ typedef $$TracksTableUpdateCompanionBuilder =
Value<String> path,
Value<String?> artUri,
Value<String?> lyrics,
Value<int> lyricsOffset,
Value<DateTime> addedAt,
});
@@ -1224,6 +1277,11 @@ class $$TracksTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get addedAt => $composableBuilder(
column: $table.addedAt,
builder: (column) => ColumnFilters(column),
@@ -1304,6 +1362,11 @@ class $$TracksTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get addedAt => $composableBuilder(
column: $table.addedAt,
builder: (column) => ColumnOrderings(column),
@@ -1343,6 +1406,11 @@ class $$TracksTableAnnotationComposer
GeneratedColumn<String> get lyrics =>
$composableBuilder(column: $table.lyrics, builder: (column) => column);
GeneratedColumn<int> get lyricsOffset => $composableBuilder(
column: $table.lyricsOffset,
builder: (column) => column,
);
GeneratedColumn<DateTime> get addedAt =>
$composableBuilder(column: $table.addedAt, builder: (column) => column);
@@ -1408,6 +1476,7 @@ class $$TracksTableTableManager
Value<String> path = const Value.absent(),
Value<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(),
Value<int> lyricsOffset = const Value.absent(),
Value<DateTime> 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<String?> artUri = const Value.absent(),
Value<String?> lyrics = const Value.absent(),
Value<int> lyricsOffset = const Value.absent(),
Value<DateTime> 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

View File

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

View File

@@ -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,12 +607,14 @@ 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),
Column(
children: [
Row(
children: [
Expanded(
@@ -661,7 +667,9 @@ class _LyricsRefreshButton extends HookConsumerWidget {
);
debugPrint('Cleared lyrics from database');
// Invalidate the track provider to refresh the UI
ref.invalidate(trackByPathProvider(trackPath));
ref.invalidate(
trackByPathProvider(trackPath),
);
debugPrint(
'Invalidated track provider for $trackPath',
);
@@ -673,6 +681,30 @@ class _LyricsRefreshButton extends HookConsumerWidget {
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.tune),
label: const Text('Adjust Timing'),
onPressed: trackAsync.maybeWhen(
data: (track) => track != null
? () {
Navigator.of(context).pop();
_showLyricsOffsetDialog(
context,
ref,
track,
trackPath,
);
}
: null,
orElse: () => null,
),
),
),
],
),
],
),
actions: [
@@ -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<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 {
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,12 +797,21 @@ class _TimedLyricsView extends HookWidget {
);
final previousIndex = useState(-1);
// Get track data to access lyrics offset
final trackAsync = ref.watch(trackByPathProvider(trackPath));
return trackAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (track) {
final lyricsOffset = track?.lyricsOffset ?? 0;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final positionMs = position.inMilliseconds;
final positionMs = position.inMilliseconds + lyricsOffset;
// Find current line index
int currentIndex = 0;
@@ -796,9 +887,10 @@ class _TimedLyricsView extends HookWidget {
: FontWeight.normal,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.7),
: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.7),
),
textAlign: TextAlign.left,
child: Text(
@@ -843,7 +935,9 @@ class _TimedLyricsView extends HookWidget {
duration: const Duration(milliseconds: 200),
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: isActive ? 20 : 16,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(
@@ -859,6 +953,8 @@ class _TimedLyricsView extends HookWidget {
);
},
);
},
);
}
}
@@ -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<db.Track?, String>((
ref,
trackPath,
) async {
final database = ref.watch(databaseProvider);
return (database.select(
database.tracks,
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
});