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,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<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,145 +797,159 @@ class _TimedLyricsView extends HookWidget {
);
final previousIndex = useState(-1);
return StreamBuilder<Duration>(
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<Duration>(
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<db.Track?, String>((
ref,
trackPath,
) async {
final database = ref.watch(databaseProvider);
return (database.select(
database.tracks,
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
});