💄 Better adjust lyrics experience

This commit is contained in:
2025-12-18 00:17:06 +08:00
parent f4b0dd8067
commit e4797fa2f9
4 changed files with 688 additions and 163 deletions

View File

@@ -160,9 +160,9 @@ class MusixmatchProvider extends LrcProvider {
final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"]; final statusCode = jsonDecode(r.body)["message"]["header"]["status_code"];
if (statusCode != 200) return null; if (statusCode != 200) return null;
final body = jsonDecode(r.body)["message"]["body"]; final body = jsonDecode(r.body)["message"]["body"];
if (body == null || !(body is Map)) return null; if (body == null || body is! Map) return null;
final tracks = body["track_list"]; final tracks = body["track_list"];
if (tracks == null || !(tracks is List) || tracks.isEmpty) return null; if (tracks == null || tracks is! List || tracks.isEmpty) return null;
// Simple "best match" - first track // Simple "best match" - first track
final track = tracks.firstWhere((t) => true, orElse: () => null); final track = tracks.firstWhere((t) => true, orElse: () => null);

View File

@@ -1,4 +1,3 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:groovybox/logic/audio_handler.dart'; import 'package:groovybox/logic/audio_handler.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';

View File

@@ -304,6 +304,7 @@ class LibraryScreen extends HookConsumerWidget {
} }
// Import lyrics if any // Import lyrics if any
if (!context.mounted) return;
if (lyricsPaths.isNotEmpty) { if (lyricsPaths.isNotEmpty) {
await _batchImportLyricsFromPaths( await _batchImportLyricsFromPaths(
context, context,
@@ -366,10 +367,12 @@ class LibraryScreen extends HookConsumerWidget {
final query = searchQuery.value.toLowerCase(); final query = searchQuery.value.toLowerCase();
filteredTracks = tracks.where((track) { filteredTracks = tracks.where((track) {
if (track.title.toLowerCase().contains(query)) return true; if (track.title.toLowerCase().contains(query)) return true;
if (track.artist?.toLowerCase().contains(query) ?? false) if (track.artist?.toLowerCase().contains(query) ?? false) {
return true; return true;
if (track.album?.toLowerCase().contains(query) ?? false) }
if (track.album?.toLowerCase().contains(query) ?? false) {
return true; return true;
}
if (track.lyrics != null) { if (track.lyrics != null) {
try { try {
final lyricsData = LyricsData.fromJsonString(track.lyrics!); final lyricsData = LyricsData.fromJsonString(track.lyrics!);

View File

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