From c22577eb18a79f515412ecffb4ed6c4a5956fbef Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:31:55 +0700 Subject: [PATCH] refactor(player): separate metadata state and lyrics rendering --- .../player_metadata_controller.dart | 176 ++ lib/providers/music_player_provider.dart | 26 + lib/screens/now_playing_screen.dart | 1984 +---------------- lib/widgets/player_lyrics.dart | 1687 ++++++++++++++ test/player_metadata_controller_test.dart | 252 +++ 5 files changed, 2203 insertions(+), 1922 deletions(-) create mode 100644 lib/controllers/player_metadata_controller.dart create mode 100644 lib/widgets/player_lyrics.dart create mode 100644 test/player_metadata_controller_test.dart diff --git a/lib/controllers/player_metadata_controller.dart b/lib/controllers/player_metadata_controller.dart new file mode 100644 index 00000000..47eb06a6 --- /dev/null +++ b/lib/controllers/player_metadata_controller.dart @@ -0,0 +1,176 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:spotiflac_android/services/playback_metadata.dart'; +import 'package:spotiflac_android/utils/int_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; + +final _log = AppLogger('PlayerMetadata'); + +/// Owns file metadata and lyrics for the full player. Requests are scoped to +/// the current queue item so a late probe cannot replace another track's data. +class PlayerMetadataController extends ChangeNotifier { + PlayerMetadataController({PlaybackMetadataReader? readMetadata}) + : _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry; + + final PlaybackMetadataReader _readMetadata; + (String, String, String?)? _requestKey; + String? _loadedPath; + Map _fallback = const {}; + int _generation = 0; + bool _disposed = false; + + String? get source => _requestKey?.$2; + Map? _metadata; + ParsedLyrics _lyrics = ParsedLyrics.empty; + bool _loading = false; + Map? get metadata => _metadata; + ParsedLyrics get lyrics => _lyrics; + bool get loading => _loading; + + Future load( + MediaItem? item, { + bool inspectUnresolvedContentUri = false, + }) async { + if (_disposed) return; + final source = item?.extras?['source']?.toString() ?? ''; + if (item == null || source.isEmpty) { + if (_requestKey == null) return; + _generation++; + _requestKey = null; + _loadedPath = null; + _replaceMetadata(const {}); + return; + } + final resolvedSource = item.extras?['resolvedSource']?.toString().trim(); + final key = (item.id, source, resolvedSource); + final sameItem = key == _requestKey; + _fallback = Map.unmodifiable(playbackAudioMetadataFromMediaItem(item)); + if (!sameItem) { + _generation++; + _requestKey = key; + _loadedPath = null; + } + if (const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme)) { + _loadedPath = source; + _replaceMetadata(_fallback); + return; + } + final path = _metadataPath(source, resolvedSource); + final unresolved = path == source && source.startsWith('content://'); + if (sameItem) { + if (metadata == null && _fallback.isNotEmpty) { + _metadata = _fallback; + notifyListeners(); + } + if (loading || _loadedPath == path) return; + } else { + _replaceMetadata(_fallback); + } + // Opening the player must not copy an unresolved SAF document. Lyrics + // requests may inspect it until playback publishes its resolved source. + if (unresolved && !inspectUnresolvedContentUri) return; + _loading = true; + notifyListeners(); + final generation = _generation; + try { + final probed = await _readMetadata(path); + if (_disposed || generation != _generation) return; + _loadedPath = path; + _replaceMetadata(mergePlaybackFileMetadata(_fallback, probed)); + } catch (error) { + if (_disposed || generation != _generation) return; + _log.w('Failed to read metadata: $error'); + _replaceMetadata(_fallback); + } + } + + void _replaceMetadata(Map value) { + _metadata = value.isEmpty ? null : Map.unmodifiable(value); + _lyrics = LyricsParser.parse((value['lyrics'] ?? '').toString()); + _loading = false; + notifyListeners(); + } + + String _metadataPath(String source, String? resolved) => + !source.startsWith('network://') && resolved?.isNotEmpty == true + ? resolved! + : source; + + Map detailsSnapshot(MediaItem? item) { + if (item == null) return const {}; + final source = item.extras?['source']?.toString() ?? ''; + final resolved = item.extras?['resolvedSource']?.toString().trim(); + final sameItem = _requestKey == (item.id, source, resolved); + return { + 'title': item.title, + 'artist': item.artist ?? '', + 'album': item.album ?? '', + ...playbackAudioMetadataFromMediaItem(item), + if (sameItem) ...?metadata, + }; + } + + /// A details sheet owns this snapshot, independently of later track changes. + /// Probing an unresolved document here does not alter the lyrics lifecycle. + Future> readDetails(MediaItem? item) async { + final fallback = detailsSnapshot(item); + final source = item?.extras?['source']?.toString() ?? ''; + final resolved = item?.extras?['resolvedSource']?.toString().trim(); + final path = _metadataPath(source, resolved); + if (_disposed || + path.isEmpty || + const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme) || + (_requestKey == (item?.id, source, resolved) && _loadedPath == path)) { + return fallback; + } + try { + return mergePlaybackFileMetadata(fallback, await _readMetadata(path)); + } catch (error) { + _log.w('Failed to read details metadata: $error'); + return fallback; + } + } + + ({String? writers, String? provider}) get credits { + String? field(String key) { + final value = metadata?[key]; + return value is String && value.trim().isNotEmpty ? value.trim() : null; + } + + return ( + writers: lyrics.writers ?? field('lyricist') ?? field('composer'), + provider: lyrics.provider ?? field('lyrics_provider'), + ); + } + + String? get qualityLabel { + final meta = metadata; + if (meta == null) return null; + final parts = []; + final format = (meta['format'] ?? meta['audio_codec'] ?? '') + .toString() + .trim() + .toUpperCase(); + if (format.isNotEmpty) parts.add(format); + final bitDepth = readPositiveInt(meta['bit_depth']) ?? 0; + if (bitDepth > 0) parts.add('$bitDepth-bit'); + final sampleRate = readPositiveInt(meta['sample_rate']) ?? 0; + if (sampleRate > 0) { + final khz = sampleRate / 1000; + parts.add( + '${khz.toStringAsFixed(khz == khz.roundToDouble() ? 0 : 1)} kHz', + ); + } + final bitrate = readPositiveInt(meta['bitrate']) ?? 0; + if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps'); + return parts.isEmpty ? null : parts.join(' · '); + } + + @override + void dispose() { + _disposed = true; + _generation++; + super.dispose(); + } +} diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index ed806fe6..2ee230cf 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -168,6 +168,32 @@ class MusicPlayerController { await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1)); } + Future<({LocalLibraryItem item, List tracks})?> + localAlbumFor(String mediaId) async { + final database = _runtime.dependencies.libraryDatabase; + final row = await database.getById(mediaId); + if (row == null) return null; + final item = LocalLibraryItem.fromJson(row); + final rows = await database.getQueueLocalAlbumTracksByKey(item.albumKey); + final tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false); + return tracks.isEmpty ? null : (item: item, tracks: tracks); + } + + Future shuffleLibrary() async { + final rows = await _runtime.dependencies.libraryDatabase.getAll(); + final media = rows + .map(LocalLibraryItem.fromJson) + .where((item) => item.filePath.trim().isNotEmpty) + .map(playableFromLocal) + .toList(); + if (media.isEmpty) return false; + final handler = await _runtime.initialize(); + media.shuffle(); + await handler.setShuffleMode(AudioServiceShuffleMode.all); + await handler.setQueueAndPlay(media); + return true; + } + Future play() async => _handler?.play(); Future pause() async => _handler?.pause(); Future stop() async => _handler?.stop(); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 97ee3de5..b76fd614 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,53 +1,39 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; -import 'package:flutter/foundation.dart' show ValueListenable, listEquals; -import 'package:flutter/rendering.dart' - show OverflowBoxFit, RenderAbstractViewport, ScrollDirection; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; -import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; -import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/app_orientation.dart'; -import 'package:spotiflac_android/services/music_player_service.dart'; -import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/file_access.dart'; -import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/isrc_utils.dart'; -import 'package:spotiflac_android/utils/lyrics_parser.dart'; -import 'package:spotiflac_android/utils/lyrics_timeline.dart'; import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; -import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; -import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart'; -import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; -import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart'; import 'package:spotiflac_android/widgets/automix_status_badge.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; -import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/lyrics_screen_awake.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; import 'package:spotiflac_android/widgets/player_track_swipe.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; @@ -75,10 +61,8 @@ final _log = AppLogger('NowPlaying'); /// the cover visually expands when the player opens. const kNowPlayingArtworkHeroTag = 'now-playing-artwork'; -const _mornyeLyricFontSize = 34.0; const _mornyeTimelinePadding = 28.0; -const _mornyeContentInset = - _mornyeTimelinePadding + MornyePlayerSlider.horizontalInset; +const _mornyeContentInset = mornyeLyricsContentInset; const _mornyeHeaderButtonSize = 48.0; const _mornyeCompactIconSize = 28.0; @@ -368,12 +352,7 @@ class NowPlayingScreen extends ConsumerStatefulWidget { class _NowPlayingScreenState extends ConsumerState { ProviderSubscription>? _mediaItemSub; ProviderSubscription? _lyricsPlayingSub; - String? _loadedSource; - String? _loadedResolvedSource; - String? _loadedMetadataPath; - Map? _metadata; - ParsedLyrics _lyrics = ParsedLyrics.empty; - bool _loadingMeta = false; + final _trackMetadata = PlayerMetadataController(); int _currentPage = 0; bool _landscape = false; bool _bottomDragForwarding = false; @@ -402,16 +381,16 @@ class _NowPlayingScreenState extends ConsumerState { @override void initState() { super.initState(); + _trackMetadata.addListener(_metadataChanged); _mediaItemSub = ref.listenManual>( currentMediaItemProvider, (previous, next) { if (previous?.value?.id != next.value?.id) _seekPreview.reset(); - _loadMetadataForItem( - next.value, - // When automatic playback advances while Lyrics is already visible, - // the lyrics toggle will not run again. Inspect an unresolved SAF URI now - // instead of leaving the new track with an empty Lyrics page. - inspectUnresolvedContentUri: _currentPage == 1, + unawaited( + _trackMetadata.load( + next.value, + inspectUnresolvedContentUri: _currentPage == 1, + ), ); }, ); @@ -426,10 +405,14 @@ class _NowPlayingScreenState extends ConsumerState { }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _loadMetadataForItem(ref.read(currentMediaItemProvider).value); + unawaited(_trackMetadata.load(ref.read(currentMediaItemProvider).value)); }); } + void _metadataChanged() { + if (mounted) setState(() {}); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -446,144 +429,17 @@ class _NowPlayingScreenState extends ConsumerState { _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); _lyricsPlayingSub?.close(); + _trackMetadata.dispose(); _artworkColorsChanged.dispose(); _seekPreview.dispose(); super.dispose(); } - void _loadMetadataForItem( - MediaItem? item, { - bool inspectUnresolvedContentUri = false, - }) { - if (item == null) return; - final source = item.extras?['source']?.toString() ?? ''; - if (source.isEmpty) return; - final resolvedSource = item.extras?['resolvedSource']?.toString(); - unawaited( - _loadMetadataFor( - source, - resolvedSource: resolvedSource, - fallbackMetadata: playbackAudioMetadataFromMediaItem(item), - inspectUnresolvedContentUri: inspectUnresolvedContentUri, - ), - ); - } - - Future _loadMetadataFor( - String source, { - String? resolvedSource, - Map fallbackMetadata = const {}, - bool inspectUnresolvedContentUri = false, - }) async { - final effectiveResolvedSource = resolvedSource?.trim(); - if (const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme)) { - if (!mounted) return; - setState(() { - _loadedSource = source; - _loadedResolvedSource = effectiveResolvedSource; - _loadedMetadataPath = source; - _loadingMeta = false; - _metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata; - _lyrics = ParsedLyrics.empty; - }); - return; - } - final path = - !source.startsWith('network://') && - effectiveResolvedSource != null && - effectiveResolvedSource.isNotEmpty - ? effectiveResolvedSource - : source; - final unresolvedContentUri = - path == source && source.startsWith('content://'); - final sameItem = - source == _loadedSource && - effectiveResolvedSource == _loadedResolvedSource; - - if (sameItem) { - if (_metadata == null && fallbackMetadata.isNotEmpty) { - setState(() => _metadata = fallbackMetadata); - } - if (_loadingMeta || _loadedMetadataPath == path) return; - if (unresolvedContentUri && !inspectUnresolvedContentUri) return; - setState(() => _loadingMeta = true); - } else { - _loadedSource = source; - _loadedResolvedSource = effectiveResolvedSource; - _loadedMetadataPath = null; - setState(() { - _loadingMeta = !unresolvedContentUri || inspectUnresolvedContentUri; - _metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata; - _lyrics = ParsedLyrics.empty; - }); - } - - // Avoid copying a restored SAF file merely because the player shell became - // visible. If the user opens Lyrics before playback resolves a local temp - // source, inspect the content URI on demand instead. - if (unresolvedContentUri && !inspectUnresolvedContentUri) return; - - try { - final meta = await readPlaybackFileMetadataWithRetry(path); - if (!mounted || - _loadedSource != source || - _loadedResolvedSource != effectiveResolvedSource) { - return; - } - setState(() { - _loadedMetadataPath = path; - _metadata = mergePlaybackFileMetadata(fallbackMetadata, meta); - _lyrics = LyricsParser.parse((meta['lyrics'] ?? '').toString()); - _loadingMeta = false; - }); - } catch (e) { - _log.w('Failed to read metadata: $e'); - if (!mounted || - _loadedSource != source || - _loadedResolvedSource != effectiveResolvedSource) { - return; - } - setState(() { - _metadata = fallbackMetadata.isEmpty ? null : fallbackMetadata; - _lyrics = ParsedLyrics.empty; - _loadingMeta = false; - }); - } - } - - String? _qualityLabel() { - final meta = _metadata; - if (meta == null) return null; - - final parts = []; - final format = (meta['format'] ?? meta['audio_codec'] ?? '') - .toString() - .trim() - .toUpperCase(); - if (format.isNotEmpty) parts.add(format); - - final bitDepth = readPositiveInt(meta['bit_depth']) ?? 0; - if (bitDepth > 0) parts.add('$bitDepth-bit'); - - final sampleRate = readPositiveInt(meta['sample_rate'])?.toDouble() ?? 0; - if (sampleRate > 0) { - final khz = sampleRate / 1000; - final khzStr = khz == khz.roundToDouble() - ? khz.toStringAsFixed(0) - : khz.toStringAsFixed(1); - parts.add('$khzStr kHz'); - } - - final bitrate = readPositiveInt(meta['bitrate']) ?? 0; - if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps'); - - if (parts.isEmpty) return null; - return parts.join(' · '); - } - bool _isExplicit(MediaItem mediaItem) { final source = mediaItem.extras?['source']?.toString(); - final loadedMetadata = source == _loadedSource ? _metadata : null; + final loadedMetadata = source == _trackMetadata.source + ? _trackMetadata.metadata + : null; return parseExplicitFlag(loadedMetadata?['explicit']) ?? parseExplicitFlag(mediaItem.extras?['explicit']) ?? false; @@ -1136,9 +992,11 @@ class _NowPlayingScreenState extends ConsumerState { void _toggleMaterialLyrics() { setState(() => _currentPage = _currentPage == 1 ? 0 : 1); if (_currentPage == 1) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, + unawaited( + _trackMetadata.load( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ), ); } } @@ -1155,9 +1013,11 @@ class _NowPlayingScreenState extends ConsumerState { }); _scheduleLyricsControlsHide(); if (_currentPage == 1) { - _loadMetadataForItem( - ref.read(currentMediaItemProvider).value, - inspectUnresolvedContentUri: true, + unawaited( + _trackMetadata.load( + ref.read(currentMediaItemProvider).value, + inspectUnresolvedContentUri: true, + ), ); } } @@ -1576,7 +1436,7 @@ class _NowPlayingScreenState extends ConsumerState { controller: controller, seekPreview: _seekPreview, colorScheme: foreground('controls'), - qualityLabel: _qualityLabel(), + qualityLabel: _trackMetadata.qualityLabel, compact: landscape, transportTopPadding: 16 + transportShift, ), @@ -1858,7 +1718,7 @@ class _NowPlayingScreenState extends ConsumerState { controller: controller, seekPreview: _seekPreview, colorScheme: colorScheme, - qualityLabel: _qualityLabel(), + qualityLabel: _trackMetadata.qualityLabel, compact: compact, ), ]; @@ -1869,10 +1729,10 @@ class _NowPlayingScreenState extends ConsumerState { required bool isActive, bool showOptions = true, }) { - if (_loadingMeta) { + if (_trackMetadata.loading) { return const Center(child: AppLoadingIndicator()); } - if (_lyrics.isEmpty) { + if (_trackMetadata.lyrics.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -1893,7 +1753,7 @@ class _NowPlayingScreenState extends ConsumerState { ), ); } - if (_lyrics.synced) { + if (_trackMetadata.lyrics.synced) { final visibility = ref.watch( settingsProvider.select( (settings) => ( @@ -1908,8 +1768,8 @@ class _NowPlayingScreenState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - _SyncedLyricsView( - lyrics: _lyrics, + SyncedLyricsView( + lyrics: _trackMetadata.lyrics, seekPreview: _seekPreview, credits: _lyricsCredits(), colorScheme: colorScheme, @@ -1932,7 +1792,7 @@ class _NowPlayingScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - _lyrics.plainText, + _trackMetadata.lyrics.plainText, style: Theme.of(context).textTheme.titleMedium?.copyWith( height: 1.6, color: colorScheme.onSurface, @@ -1945,25 +1805,18 @@ class _NowPlayingScreenState extends ConsumerState { ); } - _LyricsCredits? _lyricsCredits() { - String? metadata(String key) { - final value = _metadata?[key]; - return value is String && value.trim().isNotEmpty ? value.trim() : null; - } - - final writers = - _lyrics.writers ?? metadata('lyricist') ?? metadata('composer'); - final provider = _lyrics.provider ?? metadata('lyrics_provider'); + LyricsCredits? _lyricsCredits() { + final (:writers, :provider) = _trackMetadata.credits; if (writers == null && provider == null) return null; - return _LyricsCredits(writers: writers, provider: provider); + return LyricsCredits(writers: writers, provider: provider); } Widget? _lyricsOptionsButton(ColorScheme colorScheme) { - if (_loadingMeta || !_lyrics.synced) return null; - final pronunciation = _lyrics.lines.any( + if (_trackMetadata.loading || !_trackMetadata.lyrics.synced) return null; + final pronunciation = _trackMetadata.lyrics.lines.any( (line) => line.romanization?.trim().isNotEmpty == true, ); - final translation = _lyrics.lines.any( + final translation = _trackMetadata.lyrics.lines.any( (line) => line.translation?.trim().isNotEmpty == true, ); if (!pronunciation && !translation) return null; @@ -2361,31 +2214,23 @@ class _NowPlayingScreenState extends ConsumerState { } try { - final row = await LibraryDatabase.instance.getById(mediaItem.id); + final album = await ref + .read(musicPlayerControllerProvider) + .localAlbumFor(mediaItem.id); if (!mounted) return; - if (row != null) { - final item = LocalLibraryItem.fromJson(row); - final rows = await LibraryDatabase.instance - .getQueueLocalAlbumTracksByKey(item.albumKey); - if (!mounted) return; - final tracks = rows - .map(LocalLibraryItem.fromJson) - .toList(growable: false); - if (tracks.isNotEmpty) { - final albumArtist = (item.albumArtist ?? '').trim(); - pushViaPreferredNavigator( - context, - (_) => LocalAlbumScreen( - albumName: item.albumName, - artistName: albumArtist.isNotEmpty - ? albumArtist - : item.artistName, - coverPath: item.coverPath, - tracks: tracks, - ), - ); - return; - } + if (album != null) { + final (:item, :tracks) = album; + final albumArtist = (item.albumArtist ?? '').trim(); + pushViaPreferredNavigator( + context, + (_) => LocalAlbumScreen( + albumName: item.albumName, + artistName: albumArtist.isNotEmpty ? albumArtist : item.artistName, + coverPath: item.coverPath, + tracks: tracks, + ), + ); + return; } } catch (e) { _log.w('Failed to resolve local album: $e'); @@ -2402,22 +2247,13 @@ class _NowPlayingScreenState extends ConsumerState { Future _shuffleLibrary(MusicPlayerController controller) async { try { - final rows = await LibraryDatabase.instance.getAll(); - final media = rows - .map(LocalLibraryItem.fromJson) - .where((i) => i.filePath.trim().isNotEmpty) - .map(playableFromLocal) - .toList(); - if (media.isEmpty) { + if (!await controller.shuffleLibrary()) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)), ); return; } - media.shuffle(); - await controller.setShuffle(true); - await controller.playAll(media); if (mounted) Navigator.of(context).maybePop(); } catch (e) { if (!mounted) return; @@ -2671,28 +2507,8 @@ class _NowPlayingScreenState extends ConsumerState { void _showDetailsSheet(BuildContext context, ColorScheme colorScheme) { final mornye = context.isMornye; final mediaItem = ref.read(currentMediaItemProvider).value; - final fallback = { - if (mediaItem != null) ...{ - 'title': mediaItem.title, - 'artist': mediaItem.artist ?? '', - 'album': mediaItem.album ?? '', - ...playbackAudioMetadataFromMediaItem(mediaItem), - }, - ...?_metadata, - }; - final source = mediaItem?.extras?['source']?.toString() ?? ''; - final resolved = mediaItem?.extras?['resolvedSource']?.toString().trim(); - final path = resolved != null && resolved.isNotEmpty ? resolved : source; - // Restored Android SAF items initially carry only queue metadata. Read - // their tags on demand, even if playback has not resolved a local copy. - final metadataFuture = path.isEmpty || _loadedMetadataPath == path - ? Future.value(fallback) - : readPlaybackFileMetadataWithRetry(path) - .then((meta) => mergePlaybackFileMetadata(fallback, meta)) - .catchError((Object error) { - _log.w('Failed to read details metadata: $error'); - return fallback; - }); + final fallback = _trackMetadata.detailsSnapshot(mediaItem); + final metadataFuture = _trackMetadata.readDetails(mediaItem); showModalBottomSheet( context: context, isScrollControlled: true, @@ -3079,1682 +2895,6 @@ class _PlaybackControls extends ConsumerWidget { } } -class _SyncedLyricsView extends ConsumerStatefulWidget { - final ParsedLyrics lyrics; - final ValueListenable seekPreview; - final _LyricsCredits? credits; - final ColorScheme colorScheme; - final bool isActive; - final bool showPronunciation; - final bool showTranslation; - - const _SyncedLyricsView({ - required this.lyrics, - required this.seekPreview, - this.credits, - required this.colorScheme, - required this.isActive, - required this.showPronunciation, - required this.showTranslation, - }); - - @override - ConsumerState<_SyncedLyricsView> createState() => _SyncedLyricsViewState(); -} - -class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> - with TickerProviderStateMixin { - final ScrollController _scroll = ScrollController(); - ProviderSubscription? _positionSubscription; - ProviderSubscription? _playingSubscription; - ProviderSubscription? _loadingSubscription; - Timer? _lineBoundaryTimer; - Timer? _userScrollIdleTimer; - late List _lines; - late LyricDisplayLayout _displayLayout; - late List _lineKeys; - int _active = -1; - Set _activeLines = {}; - Duration _activeTransitionPosition = Duration.zero; - bool _playing = false; - bool _loading = false; - bool _hasStarted = false; - bool _userScrolling = false; - static const double _estimatedLyricExtent = 64; - List? _lineExtents; - List<(double, double, double)> _lineMeasurements = []; - List _pronunciationLayouts = []; - Object? _lineLayoutKey; - double? _viewportHeight; - Offset? _layoutVisibility; - late final AnimationController _rowReveal = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 420), - )..addListener(_revealTick); - List _revealFrom = []; - List _revealTo = []; - List? _layoutRowVisibility; - List? _targetLineExtents; - bool _preserveRevealAnchor = false; - late final AnimationController _scrollMotion = AnimationController( - vsync: this, - duration: LyricScrollMotion.duration, - value: 1, - ); - double _scrollMotionDistance = 0; - int _scrollMotionFocus = 0; - - void _stopScrollMotion() { - _scrollMotion.stop(); - _scrollMotion.value = 1; - } - - void _startScrollMotion(int row, double distance) { - if (distance.abs() < 0.5 || - widget.seekPreview.value != null || - MediaQuery.disableAnimationsOf(context)) { - _stopScrollMotion(); - return; - } - setState(() { - _scrollMotionDistance = distance; - _scrollMotionFocus = row; - }); - _scrollMotion.forward(from: 0); - } - - void _revealTick() { - if (mounted) setState(() {}); - } - - List get _rowVisibility { - final progress = Curves.easeInOutCubic.transform(_rowReveal.value); - return List.generate( - _lines.length, - (index) => - _revealFrom[index] + - (_revealTo[index] - _revealFrom[index]) * progress, - ); - } - - void _updateRowVisibility({bool immediate = false, bool sameFocus = false}) { - _preserveRevealAnchor = sameFocus; - final target = List.generate(_lines.length, (index) { - final line = _lines[index]; - if (line.text.isEmpty) return _activeLines.contains(index) ? 1.0 : 0.0; - if (line.isBackground) return index <= _active ? 1.0 : 0.0; - return 1.0; - }); - if (immediate || MediaQuery.disableAnimationsOf(context)) { - _rowReveal.stop(); - _revealFrom = target; - _revealTo = target; - return; - } - if (listEquals(target, _revealTo)) return; - _revealFrom = _rowVisibility; - _revealTo = target; - _rowReveal.forward(from: 0); - } - - // The header stays fixed when controls collapse. Anchor lyrics to it, not - // to a fraction of the growing viewport, including during the transition. - double get _focusInset => - (MediaQuery.sizeOf(context).height * 0.06).clamp(16.0, 48.0); - - @override - void initState() { - super.initState(); - _resetLineKeys(); - widget.seekPreview.addListener(_previewChanged); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Appearance can rebuild when transport controls fade (for example with - // custom glass clarity). Keep the existing playback subscription and the - // user's scroll ownership; only new lyrics/page activation should reset it. - if (_positionSubscription == null && widget.isActive) { - _syncPositionSubscription(); - } - } - - @override - void didUpdateWidget(covariant _SyncedLyricsView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.seekPreview != widget.seekPreview) { - oldWidget.seekPreview.removeListener(_previewChanged); - widget.seekPreview.addListener(_previewChanged); - } - if (oldWidget.lyrics != widget.lyrics) { - _resetLineKeys(); - } - if (oldWidget.showPronunciation != widget.showPronunciation || - oldWidget.showTranslation != widget.showTranslation) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_userScrolling && _scroll.hasClients) { - // Cancel a pending line-scroll once, then let layout preserve the - // current anchor throughout the supplement animation. - _scroll.jumpTo(_scroll.offset); - } - }); - } - if (oldWidget.isActive != widget.isActive || - oldWidget.lyrics != widget.lyrics) { - _syncPositionSubscription(); - } - } - - void _resetLineKeys() { - _stopScrollMotion(); - _hasStarted = false; - _lineExtents = null; - _lineLayoutKey = null; - _lines = lyricsTimelineWithGaps(widget.lyrics.lines); - _rowReveal.stop(); - _revealFrom = _revealTo = [ - for (final line in _lines) - if (line.isBackground || line.text.isEmpty) 0.0 else 1.0, - ]; - _layoutRowVisibility = null; - _targetLineExtents = null; - _displayLayout = LyricDisplayLayout(_lines); - _lineKeys = List.generate( - _lines.length, - (index) => GlobalKey(debugLabel: 'lyric-line-$index'), - growable: false, - ); - } - - void _syncPositionSubscription() { - _positionSubscription?.close(); - _playingSubscription?.close(); - _loadingSubscription?.close(); - _lineBoundaryTimer?.cancel(); - _positionSubscription = null; - _playingSubscription = null; - _loadingSubscription = null; - if (!widget.isActive) { - _stopScrollMotion(); - return; - } - _userScrollIdleTimer?.cancel(); - _userScrolling = false; - - final position = _displayPosition; - _playing = ref.read(playbackPlayingProvider); - _loading = ref.read(playbackLoadingProvider); - _active = _activeIndexAt(position); - _activeLines = activeLyricIndices(_lines, position, _active); - _updateRowVisibility(immediate: true); - _activeTransitionPosition = position; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true)); - }); - _scheduleNextLine(position); - _positionSubscription = ref.listenManual( - playbackPositionProvider, - (previous, next) { - if (widget.seekPreview.value != null) return; - final active = _activeIndexAt(next); - _setActiveLine(active, position: next); - _scheduleNextLine(next); - }, - ); - _playingSubscription = ref.listenManual(playbackPlayingProvider, ( - previous, - next, - ) { - _playing = next; - final position = _displayPosition; - _setActiveLine(_activeIndexAt(position), position: position); - _scheduleNextLine(position); - }); - _loadingSubscription = ref.listenManual(playbackLoadingProvider, ( - previous, - next, - ) { - _loading = next; - final position = _displayPosition; - _setActiveLine(_activeIndexAt(position), position: position); - _scheduleNextLine(position); - }); - } - - Duration get _displayPosition => - widget.seekPreview.value ?? ref.read(playbackPositionProvider); - - void _previewChanged() { - if (!mounted || !widget.isActive) return; - final position = _displayPosition; - final wasUserScrolling = _userScrolling; - _userScrolling = false; - _userScrollIdleTimer?.cancel(); - final active = _activeIndexAt(position); - if (active == _active && (wasUserScrolling || active < 0)) { - unawaited(_maybeAutoScroll(active)); - } - _setActiveLine(active, position: position); - _scheduleNextLine(position); - } - - int _activeIndexAt(Duration position) { - // Scrubbing is an explicit preview, even at zero or while audio buffers. - if (widget.seekPreview.value != null) { - return LyricsParser.activeIndex(_lines, position); - } - if (context.isMornye) { - // Read one transport snapshot: playing/loading derived providers can - // notify separately during the same playback event. - final playback = ref.read(playbackStateProvider).value; - if (playback?.processingState == AudioProcessingState.loading || - playback?.processingState == AudioProcessingState.buffering) { - return -1; - } - _hasStarted = - _hasStarted || playback?.playing == true || position > Duration.zero; - if (!_hasStarted) return -1; - } - return LyricsParser.activeIndex(_lines, position); - } - - void _setActiveLine(int active, {required Duration position}) { - if (!mounted) return; - final activeLines = activeLyricIndices(_lines, position, active); - final indexChanged = active != _active; - final previousFocus = _active < 0 - ? -1 - : _displayLayout.focusForLine[_active]; - if (!indexChanged && - activeLines.length == _activeLines.length && - activeLines.containsAll(_activeLines)) { - return; - } - setState(() { - _active = active; - _activeLines = activeLines; - _activeTransitionPosition = position; - _updateRowVisibility( - sameFocus: - active >= 0 && _displayLayout.focusForLine[active] == previousFocus, - ); - }); - if (!indexChanged || - (active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && active == _active) unawaited(_maybeAutoScroll(active)); - }); - } - - void _scheduleNextLine(Duration position) { - _lineBoundaryTimer?.cancel(); - if (!widget.isActive || - !_playing || - _loading || - widget.seekPreview.value != null) { - return; - } - - final lines = _lines; - final dueIndex = syncedLyricsDueLineIndex( - lineStarts: lines.map((line) => line.time).toList(growable: false), - currentIndex: _active, - position: position, - ); - _setActiveLine(dueIndex, position: position); - - final nextIndex = dueIndex + 1; - Duration? nextBoundary = nextIndex < lines.length - ? lines[nextIndex].time - : null; - for (final index in _activeLines) { - final end = lines[index].end; - if ((index != dueIndex || - lines[index].voice != null || - lines[index].isBackground) && - end != null && - end > position && - (nextBoundary == null || end < nextBoundary)) { - nextBoundary = end; - } - } - if (nextBoundary == null) return; - final boundary = nextBoundary; - _lineBoundaryTimer = Timer(boundary - position, () { - if (!mounted || !widget.isActive || !_playing || _loading) return; - _scheduleNextLine(boundary); - }); - } - - @override - void dispose() { - widget.seekPreview.removeListener(_previewChanged); - _positionSubscription?.close(); - _playingSubscription?.close(); - _loadingSubscription?.close(); - _lineBoundaryTimer?.cancel(); - _userScrollIdleTimer?.cancel(); - _scroll.dispose(); - _rowReveal.dispose(); - _scrollMotion.dispose(); - super.dispose(); - } - - void _measureMornyeLines(double width) { - final style = _mornyeLyricStyle(context); - final scaler = MediaQuery.textScalerOf(context); - final direction = Directionality.of(context); - final locale = Localizations.maybeLocaleOf(context); - final key = (width, style, scaler, direction, locale); - if (_lineLayoutKey == key) return; - _lineLayoutKey = key; - final painter = TextPainter( - textDirection: direction, - textScaler: scaler, - locale: locale, - ); - final measurements = <(double, double, double)>[]; - final layouts = List.filled(_lines.length, null); - for (final index in _displayLayout.lineOrder) { - final line = _lines[index]; - if (line.text.isEmpty) { - measurements.add((56, 0, 0)); - continue; - } - painter.text = TextSpan( - text: line.text, - style: _mornyeLyricStyle(context, background: line.isBackground), - ); - painter.layout(maxWidth: width); - final padding = _linePadding(index).vertical; - var height = painter.height + padding; - var pronunciationHeight = 0.0; - var translationHeight = 0.0; - LyricPronunciationLayout? pronunciationLayout; - for (final (text, style, _, translation) in _lyricSupplements( - context, - line, - )) { - painter.text = TextSpan(text: text, style: style); - painter.layout(maxWidth: width); - if (translation) { - translationHeight = 6 + painter.height; - } else { - pronunciationHeight = 6 + painter.height; - final aligned = LyricPronunciationLayout.measure( - line: line, - primaryStyle: _mornyeLyricStyle( - context, - background: line.isBackground, - ), - pronunciationStyle: style, - maxWidth: width, - textScaler: scaler, - textDirection: direction, - locale: locale, - ); - if (aligned != null) { - pronunciationLayout = aligned; - height = aligned.primaryHeight + padding; - pronunciationHeight = aligned.pronunciationHeight; - } - } - } - measurements.add((height, pronunciationHeight, translationHeight)); - layouts[index] = pronunciationLayout; - } - _lineMeasurements = measurements; - _pronunciationLayouts = layouts; - painter.dispose(); - } - - Future _maybeAutoScroll(int index, {bool immediate = false}) async { - // A short intro may have no countdown row. Still return to the first - // upcoming lyric when the user scrubs back before any vocals. - if (index < 0 && widget.seekPreview.value != null) index = 0; - if (_userScrolling || - index < 0 || - index >= _lines.length || - !_scroll.hasClients) { - return; - } - index = _displayLayout.focusForLine[index]; - final row = _displayLayout.rowForLine[index]; - final duration = immediate || MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); - if (duration == Duration.zero) _stopScrollMotion(); - final extents = _targetLineExtents; - if (context.isMornye && extents != null && row < extents.length) { - final position = _scroll.position; - final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent); - final offset = target.clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if (duration == Duration.zero) { - _scroll.jumpTo(offset); - } else { - _startScrollMotion(row, offset - position.pixels); - await _scroll.animateTo( - offset, - duration: duration, - curve: Curves.easeOutCubic, - ); - } - return; - } - if (index < _lineKeys.length) { - final lineContext = _lineKeys[index].currentContext; - if (lineContext != null) { - final object = lineContext.findRenderObject(); - final viewport = object == null - ? null - : RenderAbstractViewport.maybeOf(object); - if (duration != Duration.zero && viewport != null) { - _startScrollMotion( - row, - viewport.getOffsetToReveal(object!, 0.5).offset - _scroll.offset, - ); - } - await Scrollable.ensureVisible( - lineContext, - alignment: 0.5, - alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, - duration: duration, - curve: Curves.easeOutCubic, - ); - return; - } - } - - final position = _scroll.position; - final target = syncedLyricsEstimatedOffset( - index: row, - estimatedLineExtent: _estimatedLyricExtent, - ); - final clamped = target.clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if (duration == Duration.zero) { - _scroll.jumpTo(clamped.toDouble()); - } else { - _startScrollMotion(row, clamped - position.pixels); - await _scroll.animateTo( - clamped.toDouble(), - duration: duration, - curve: Curves.easeOutCubic, - ); - } - if (!mounted || - _userScrolling || - (_active >= 0 && index != _displayLayout.focusForLine[_active]) || - index >= _lineKeys.length) { - return; - } - final lineContext = _lineKeys[index].currentContext; - if (lineContext != null && lineContext.mounted) { - await Scrollable.ensureVisible( - lineContext, - alignment: 0.5, - alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, - duration: duration == Duration.zero - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOut, - ); - } - } - - @override - Widget build(BuildContext context) => TweenAnimationBuilder( - tween: Tween( - end: Offset( - widget.showPronunciation ? 1 : 0, - widget.showTranslation ? 1 : 0, - ), - ), - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 420), - curve: Curves.easeInOutCubic, - builder: (context, visibility, _) => _buildLyrics(context, visibility), - ); - - Widget _buildLyrics(BuildContext context, Offset visibility) { - final lines = _lines; - final active = _active; - final rowVisibility = _rowVisibility; - final loading = ref.watch(playbackLoadingProvider); - final mornye = context.isMornye; - final highContrast = MediaQuery.highContrastOf(context); - final blurLyrics = - mornye && - !highContrast && - // Every defocused line is filtered again on each playback frame. - ref.watch(mornyeLiquidGlassProvider); - final motion = MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 280); - - return NotificationListener( - onNotification: (notification) { - if (notification.depth != 0 || - notification.metrics.axis != Axis.vertical) { - return false; - } - final dragging = - (notification is ScrollStartNotification && - notification.dragDetails != null) || - (notification is ScrollUpdateNotification && - notification.dragDetails != null) || - (notification is UserScrollNotification && - notification.direction != ScrollDirection.idle); - if (dragging) { - _stopScrollMotion(); - _userScrolling = true; - _userScrollIdleTimer?.cancel(); - } else if (notification is ScrollEndNotification && _userScrolling) { - // Start the return-to-playback timeout only after the drag and its - // momentum finish. A direction notification is not an idle event: - // a long drag can keep that direction while controls fade in/out. - _userScrollIdleTimer?.cancel(); - _userScrollIdleTimer = Timer(const Duration(seconds: 4), () { - if (!mounted || !widget.isActive) return; - _userScrolling = false; - unawaited(_maybeAutoScroll(_active)); - }); - } - return false; - }, - child: LayoutBuilder( - builder: (context, constraints) { - final inset = mornye ? _mornyeContentInset : 24.0; - final contentWidth = (constraints.maxWidth - inset * 2).clamp( - 0.0, - double.infinity, - ); - // Lay out at the existing lyric width, then fit the whole line to - // the timeline. Scaling text, phrase gaps and supplements together - // preserves wrapping instead of pushing extra words onto a new row. - final layoutWidth = (constraints.maxWidth - 48).clamp( - 0.0, - double.infinity, - ); - final lyricScale = mornye && layoutWidth > 0 - ? contentWidth / layoutWidth - : 1.0; - if (mornye) { - final previousExtents = _lineExtents; - _measureMornyeLines(layoutWidth); - // Reuse text measurements; only interpolate row heights as the - // supplements fade. Scrolling follows the same animation clock. - final fullExtents = List.generate(_lineMeasurements.length, (row) { - final (primary, pronunciation, translation) = - _lineMeasurements[row]; - final index = _displayLayout.lineOrder[row]; - if (_lines[index].text.isEmpty) return primary; - final padding = _linePadding(index).vertical; - return padding + - (primary - - padding + - pronunciation * visibility.dx + - translation * visibility.dy) * - lyricScale; - }); - _lineExtents = List.generate(fullExtents.length, (row) { - return fullExtents[row] * - rowVisibility[_displayLayout.lineOrder[row]]; - }); - _targetLineExtents = List.generate(fullExtents.length, (row) { - return fullExtents[row] * - _revealTo[_displayLayout.lineOrder[row]]; - }); - if (previousExtents != null && - ((_layoutVisibility != null && - _layoutVisibility != visibility) || - (_preserveRevealAnchor && - _layoutRowVisibility != null && - !listEquals(_layoutRowVisibility, rowVisibility))) && - previousExtents.length == _lineExtents!.length && - _scroll.hasClients) { - var anchor = _active < 0 - ? 0 - : _displayLayout.rowForLine[_displayLayout - .focusForLine[_active]]; - if (_userScrolling) { - anchor = 0; - var extent = 0.0; - while (anchor < previousExtents.length && - extent + previousExtents[anchor] <= _scroll.offset) { - extent += previousExtents[anchor++]; - } - } - var correction = 0.0; - for (var i = 0; i < anchor; i++) { - correction += _lineExtents![i] - previousExtents[i]; - } - // Correct before the ListView lays out/paints. A post-frame - // jump leaves every painted frame one animation step behind. - if (correction != 0) _scroll.position.correctBy(correction); - } - } - _layoutVisibility = visibility; - _layoutRowVisibility = rowVisibility; - if (_viewportHeight != constraints.maxHeight) { - _viewportHeight = constraints.maxHeight; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(_maybeAutoScroll(_active, immediate: true)); - } - }); - } - final topPadding = mornye - ? _focusInset - : syncedLyricsCenterPadding( - viewportDimension: constraints.maxHeight, - estimatedLineExtent: _estimatedLyricExtent, - ); - // Leave enough trailing space for the final line to reach the same - // upper focus position as every other line. - final bottomPadding = mornye && _lineExtents!.isNotEmpty - ? (constraints.maxHeight - _lineExtents!.last - topPadding).clamp( - topPadding, - double.infinity, - ) - : topPadding; - return ListView.builder( - controller: _scroll, - itemExtentBuilder: mornye - ? (index, _) => index < lines.length - ? _lineExtents![index] - : widget.credits!.heightFor(context, contentWidth) - : null, - padding: EdgeInsets.fromLTRB( - inset, - topPadding, - inset, - bottomPadding, - ), - itemCount: lines.length + (widget.credits == null ? 0 : 1), - itemBuilder: (context, row) { - if (row == lines.length) return widget.credits!; - final index = _displayLayout.lineOrder[row]; - final line = lines[index]; - final leadIndex = _displayLayout.leadForLine[index]; - final textAlign = _lyricTextAlign(context, lines[leadIndex]); - final reveal = rowVisibility[index]; - if (reveal == 0) return SizedBox.shrink(key: _lineKeys[index]); - final isActive = _activeLines.contains(index); - final isPast = index < active; - - final color = mornye - ? Colors.white - : isActive - ? widget.colorScheme.onSurface - : isPast - ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) - : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); - - if (line.text.isEmpty) { - return _revealRow( - index, - reveal, - textAlign, - Padding( - padding: EdgeInsets.symmetric( - vertical: context.tokens.lyricsLinePaddingV, - ), - child: SizedBox( - height: 24, - child: Align( - alignment: mornye - ? Alignment.centerLeft - : Alignment.center, - child: - isActive && - widget.isActive && - (!loading || widget.seekPreview.value != null) - ? ValueListenableBuilder( - valueListenable: widget.seekPreview, - builder: (context, preview, _) => - LyricGapIndicator( - key: ValueKey(line.time), - start: line.time, - end: line.end!, - color: color, - position: preview, - ), - ) - : null, - ), - ), - ), - ); - } - - final timed = - isActive && - (line.hasWordTiming || line.romanizationWords.isNotEmpty); - Widget content; - if (timed) { - content = _WordHighlightedLyricLine( - line: line, - colorScheme: widget.colorScheme, - animate: widget.isActive, - initialPosition: _activeTransitionPosition, - seekPreview: widget.seekPreview, - supplementVisibility: visibility, - pronunciationLayout: mornye - ? _pronunciationLayouts[index] - : null, - textAlign: textAlign, - ); - } else { - content = Text( - line.text, - textAlign: textAlign, - style: - (mornye || isActive - ? Theme.of(context).textTheme.headlineSmall - : Theme.of(context).textTheme.titleLarge) - ?.copyWith( - height: context.tokens.lyricsLineHeight, - fontSize: line.isBackground - ? (mornye ? _mornyeLyricFontSize * 0.68 : 18) - : mornye - ? _mornyeLyricFontSize - : null, - fontWeight: mornye || isActive - ? FontWeight.bold - : FontWeight.w500, - color: color, - ), - ); - content = _withLyricSupplements( - context, - line, - content, - color, - visibility: visibility, - pronunciationLayout: mornye - ? _pronunciationLayouts[index] - : null, - textAlign: textAlign, - ); - } - content = AnimatedSwitcher( - duration: const Duration(milliseconds: 320), - reverseDuration: const Duration(milliseconds: 260), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: KeyedSubtree( - key: ValueKey(timed), - child: SizedBox(width: double.infinity, child: content), - ), - ); - if (mornye) { - final activeLead = active < 0 - ? -1 - : _displayLayout.focusForLine[active]; - final distance = activeLead < 0 - ? row + 1 - : (_displayLayout.rowForLine[leadIndex] - - _displayLayout.rowForLine[activeLead]) - .abs(); - // Nearby lines need visible defocus at the larger lyric size; - // progressively soften lines further from the current one. - final sigma = !blurLyrics || isActive || leadIndex == activeLead - ? 0.0 - : active < 0 - ? 4.8 - : distance == 1 - ? 2.4 - : distance == 2 - ? 3.6 - : 4.8; - content = TweenAnimationBuilder( - tween: Tween(end: sigma), - duration: motion, - child: RepaintBoundary(child: content), - builder: (context, value, child) => ImageFiltered( - enabled: value > 0, - imageFilter: ImageFilter.blur(sigmaX: value, sigmaY: value), - child: child, - ), - ); - content = FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.topLeft, - child: SizedBox(width: layoutWidth, child: content), - ); - } - - content = LyricScrollMotion( - progress: _scrollMotion, - distance: _scrollMotionDistance, - rowsAfterFocus: row - _scrollMotionFocus, - enabled: !isActive, - child: content, - ); - return _revealRow( - index, - reveal, - textAlign, - Padding( - padding: _linePadding(index), - child: GestureDetector( - onTap: () => - ref.read(musicPlayerControllerProvider).seek(line.time), - child: AnimatedScale( - scale: mornye || isActive ? 1.0 : 0.96, - alignment: Alignment.center, - duration: const Duration(milliseconds: 280), - curve: Curves.easeOutCubic, - child: AnimatedOpacity( - opacity: mornye - ? (isActive || highContrast ? 1 : 0.48) - : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), - duration: const Duration(milliseconds: 280), - child: content, - ), - ), - ), - ), - ); - }, - ); - }, - ), - ); - } - - Widget _revealRow( - int index, - double visibility, - TextAlign align, - Widget child, - ) { - return KeyedSubtree( - key: _lineKeys[index], - child: !_lines[index].isBackground && _lines[index].text.isNotEmpty - ? child - : IgnorePointer( - ignoring: visibility < 1, - child: LyricSupplementTransition( - visibility: visibility, - alignment: align == TextAlign.right - ? Alignment.topRight - : align == TextAlign.center - ? Alignment.topCenter - : Alignment.topLeft, - // A fixed-extent row is already shrinking. Measure its - // contents at full height so fitting does not scale it twice. - child: OverflowBox( - minHeight: 0, - maxHeight: double.infinity, - fit: OverflowBoxFit.deferToChild, - alignment: Alignment.topCenter, - child: child, - ), - ), - ), - ); - } - - EdgeInsets _linePadding(int index) { - final row = _displayLayout.rowForLine[index]; - final order = _displayLayout.lineOrder; - final hasBackingBelow = - row + 1 < order.length && - _lines[order[row + 1]].isBackground && - _displayLayout.leadForLine[order[row + 1]] == - _displayLayout.leadForLine[index]; - return EdgeInsets.only( - top: _lines[index].isBackground ? 6 : context.tokens.lyricsLinePaddingV, - bottom: hasBackingBelow ? 0 : context.tokens.lyricsLinePaddingV, - ); - } -} - -class _LyricsCredits extends StatelessWidget { - const _LyricsCredits({this.writers, this.provider}); - - final String? writers; - final String? provider; - - String _text(BuildContext context) => [ - if (writers != null) context.l10n.nowPlayingWrittenBy(writers!), - if (provider != null) context.l10n.nowPlayingLyricsProvider(provider!), - ].join('\n'); - - TextStyle _style(BuildContext context) => - Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: context.isMornye ? 17 : 14, - fontWeight: FontWeight.w600, - height: 1.4, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.48), - ); - - double heightFor(BuildContext context, double width) { - final painter = TextPainter( - text: TextSpan(text: _text(context), style: _style(context)), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - )..layout(maxWidth: width.clamp(0, double.infinity)); - final height = painter.height + 32; - painter.dispose(); - return height; - } - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Text(_text(context), style: _style(context)), - ); -} - -Iterable<(String, TextStyle, List, bool)> _lyricSupplements( - BuildContext context, - LyricLine line, -) sync* { - final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); - for (final (text, translation) in [ - (line.romanization, false), - (line.translation, true), - ]) { - if (text == null || text.trim().isEmpty) continue; - yield ( - text, - base.copyWith( - fontSize: - (context.isMornye - ? (translation ? 18.0 : 22.0) - : (translation ? 14.0 : 16.0)) * - (line.isBackground ? 0.8 : 1), - height: 1.35, - fontWeight: context.isMornye - ? (translation ? FontWeight.w600 : FontWeight.bold) - : FontWeight.w500, - ), - translation ? const [] : line.romanizationWords, - translation, - ); - } -} - -TextStyle _mornyeLyricStyle(BuildContext context, {bool background = false}) => - (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith( - fontSize: _mornyeLyricFontSize * (background ? 0.68 : 1), - height: context.tokens.lyricsLineHeight, - fontWeight: FontWeight.bold, - ); - -TextAlign _lyricTextAlign(BuildContext context, LyricLine line) { - final voice = line.voice; - if (voice == null) { - return context.isMornye ? TextAlign.start : TextAlign.center; - } - if (voice.isGroup) return TextAlign.left; - // Provider declarations can be missing or ordered by first appearance. - // The standard V labels retain their meaning regardless of that order. - switch (voice.id.toLowerCase()) { - case 'v1': - case 'v3': - return TextAlign.left; - case 'v2': - return TextAlign.right; - } - return voice.index.isEven ? TextAlign.left : TextAlign.right; -} - -Widget _withLyricSupplements( - BuildContext context, - LyricLine line, - Widget primary, - Color color, { - required Offset visibility, - required LyricPronunciationLayout? pronunciationLayout, - required TextAlign textAlign, - Widget Function(String, List, TextStyle)? timedText, - Widget Function(String, List, TextStyle)? timedSupplementText, -}) { - if (line.romanization == null && line.translation == null) return primary; - final alignment = switch (textAlign) { - TextAlign.right => Alignment.topRight, - TextAlign.center => Alignment.topCenter, - _ => Alignment.topLeft, - }; - final supplements = _lyricSupplements(context, line).toList(); - Widget withSupplements(Widget primary, {bool aligned = false}) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - primary, - for (final (text, style, words, translation) in supplements) - if ((translation || !aligned) && - (translation ? visibility.dy : visibility.dx) > 0) - LyricSupplementTransition( - alignment: alignment, - visibility: translation ? visibility.dy : visibility.dx, - child: Padding( - padding: const EdgeInsets.only(top: 6), - child: words.isNotEmpty && timedSupplementText != null - ? timedSupplementText(text, words, style) - : Text( - text, - textAlign: textAlign, - style: style.copyWith( - color: color.withValues(alpha: color.a * 0.8), - ), - ), - ), - ), - ], - ); - if (!context.isMornye || - line.romanizationWords.isEmpty || - line.romanization?.trim().isNotEmpty != true) { - return withSupplements(primary); - } - final primaryStyle = _mornyeLyricStyle( - context, - background: line.isBackground, - ); - final pronunciationStyle = supplements - .firstWhere((supplement) => !supplement.$4) - .$2; - final layout = pronunciationLayout; - if (layout == null) return withSupplements(primary); - return withSupplements( - AlignedLyricPronunciation( - layout: layout, - visibility: visibility.dx, - primaryStyle: primaryStyle, - pronunciationStyle: pronunciationStyle, - textAlign: textAlign, - pronunciationBuilder: timedSupplementText, - textBuilder: - timedText ?? - (text, words, style) => Text( - text, - textAlign: textAlign, - style: style.copyWith(color: color), - ), - ), - aligned: true, - ); -} - -class _WordHighlightedLyricLine extends ConsumerStatefulWidget { - final LyricLine line; - final ColorScheme colorScheme; - final bool animate; - final Duration initialPosition; - final ValueListenable seekPreview; - final Offset supplementVisibility; - final LyricPronunciationLayout? pronunciationLayout; - final TextAlign textAlign; - - const _WordHighlightedLyricLine({ - required this.line, - required this.colorScheme, - required this.animate, - required this.initialPosition, - required this.seekPreview, - required this.supplementVisibility, - required this.pronunciationLayout, - required this.textAlign, - }); - - @override - ConsumerState<_WordHighlightedLyricLine> createState() => - _WordHighlightedLyricLineState(); -} - -class _WordHighlightedLyricLineState - extends ConsumerState<_WordHighlightedLyricLine> - with SingleTickerProviderStateMixin { - late final AnimationController _animationClock; - final Stopwatch _elapsedClock = Stopwatch(); - ProviderSubscription? _positionSubscription; - ProviderSubscription? _playingSubscription; - ProviderSubscription? _loadingSubscription; - - late Duration _anchorPosition; - Duration _anchorElapsed = Duration.zero; - bool _playing = false; - bool _loading = false; - - bool get _shouldAnimate => - widget.animate && - _playing && - !_loading && - widget.seekPreview.value == null; - - Duration _positionAt({required bool advance}) { - return interpolatedSyncedLyricsPosition( - anchorPosition: _anchorPosition, - elapsedSinceAnchor: _elapsedClock.elapsed - _anchorElapsed, - isPlaying: advance, - ); - } - - @override - void initState() { - super.initState(); - _elapsedClock.start(); - _anchorElapsed = _elapsedClock.elapsed; - _anchorPosition = widget.initialPosition; - _playing = ref.read(playbackPlayingProvider); - _loading = ref.read(playbackLoadingProvider); - _animationClock = AnimationController( - vsync: this, - duration: const Duration(seconds: 1), - ); - widget.seekPreview.addListener(_previewChanged); - _positionSubscription = ref.listenManual( - playbackPositionProvider, - (previous, next) => _updateReportedPosition(next), - ); - _playingSubscription = ref.listenManual( - playbackPlayingProvider, - (previous, next) => _updateTransportState(playing: next), - ); - _loadingSubscription = ref.listenManual( - playbackLoadingProvider, - (previous, next) => _updateTransportState(loading: next), - ); - _syncAnimationClock(); - } - - @override - void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.seekPreview != widget.seekPreview) { - oldWidget.seekPreview.removeListener(_previewChanged); - widget.seekPreview.addListener(_previewChanged); - _previewChanged(); - } - if (oldWidget.line != widget.line || - oldWidget.initialPosition != widget.initialPosition) { - _anchorAt(widget.initialPosition); - } - if (oldWidget.animate != widget.animate) { - _anchorAt( - _positionAt(advance: oldWidget.animate && _playing && !_loading), - ); - _syncAnimationClock(); - } - } - - Duration _currentPosition() { - return widget.seekPreview.value ?? _positionAt(advance: _shouldAnimate); - } - - void _previewChanged() { - if (!mounted) return; - _anchorAt(widget.seekPreview.value ?? ref.read(playbackPositionProvider)); - _syncAnimationClock(); - setState(() {}); - } - - void _anchorAt(Duration position) { - _anchorPosition = position; - _anchorElapsed = _elapsedClock.elapsed; - } - - void _updateReportedPosition(Duration position) { - if (!mounted || widget.seekPreview.value != null) return; - final predicted = _currentPosition(); - _anchorAt( - _shouldAnimate - ? reconcileSyncedLyricsPosition( - predictedPosition: predicted, - reportedPosition: position, - ) - : position, - ); - setState(() {}); - } - - void _updateTransportState({bool? playing, bool? loading}) { - if (!mounted) return; - final position = _currentPosition(); - if (playing != null) _playing = playing; - if (loading != null) _loading = loading; - _anchorAt( - widget.seekPreview.value ?? - (_shouldAnimate ? position : ref.read(playbackPositionProvider)), - ); - _syncAnimationClock(); - setState(() {}); - } - - void _syncAnimationClock() { - if (_shouldAnimate) { - if (!_animationClock.isAnimating) { - _animationClock.repeat(); - } - } else { - _animationClock.stop(); - } - } - - Duration _segmentEnd(List words, int index) { - final word = words[index]; - final start = word.time; - if (word.end != null && word.end! >= start) return word.end!; - if (index + 1 < words.length) { - final next = words[index + 1].time; - if (next > start) return next; - } - final lineEnd = widget.line.end; - if (lineEnd != null && lineEnd > start) return lineEnd; - return start + const Duration(milliseconds: 650); - } - - @override - void dispose() { - widget.seekPreview.removeListener(_previewChanged); - _positionSubscription?.close(); - _playingSubscription?.close(); - _loadingSubscription?.close(); - _animationClock.dispose(); - _elapsedClock.stop(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _buildHighlightedLine(context); - } - - Widget _buildHighlightedLine(BuildContext context) { - final style = - (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) - .copyWith( - fontSize: widget.line.isBackground - ? (context.isMornye ? _mornyeLyricFontSize * 0.68 : 18) - : context.isMornye - ? _mornyeLyricFontSize - : null, - height: context.tokens.lyricsLineHeight, - fontWeight: FontWeight.bold, - ); - final primary = widget.line.hasWordTiming - ? _buildTimedText(widget.line.text, widget.line.words, style) - : Text( - widget.line.text, - textAlign: widget.textAlign, - style: style.copyWith(color: widget.colorScheme.onSurface), - ); - // Both scripts share this state's position interpolation and animation - // clock, including pause, seek and track changes. - return _withLyricSupplements( - context, - widget.line, - primary, - widget.colorScheme.onSurface, - timedText: _buildTimedText, - timedSupplementText: (text, words, style) => - _buildTimedText(text, words, style, lift: false), - visibility: widget.supplementVisibility, - pronunciationLayout: widget.pronunciationLayout, - textAlign: widget.textAlign, - ); - } - - Widget _buildTimedText( - String text, - List words, - TextStyle style, { - bool lift = true, - }) { - final highlightedColor = widget.colorScheme.onSurface; - final mornye = context.isMornye; - final pendingColor = mornye - ? Colors.white.withValues(alpha: 0.4) - : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6); - final segments = []; - final starts = []; - final ends = []; - for (var index = 0; index < words.length; index++) { - final word = words[index]; - segments.add(word.text); - starts.add(word.time); - ends.add(_segmentEnd(words, index)); - } - - return _SweepingTimedLyricText( - segments: segments, - starts: starts, - ends: ends, - currentPosition: _currentPosition, - repaint: _animationClock, - style: style, - textAlign: widget.textAlign, - pendingColor: pendingColor, - highlightedColor: highlightedColor, - semanticsLabel: text, - liftEnabled: lift && mornye && !MediaQuery.disableAnimationsOf(context), - ); - } -} - -class _SweepingTimedLyricText extends StatefulWidget { - final List segments; - final List starts; - final List ends; - final Duration Function() currentPosition; - final Listenable repaint; - final TextStyle style; - final TextAlign textAlign; - final Color pendingColor; - final Color highlightedColor; - final String semanticsLabel; - final bool liftEnabled; - - const _SweepingTimedLyricText({ - required this.segments, - required this.starts, - required this.ends, - required this.currentPosition, - required this.repaint, - required this.style, - required this.textAlign, - required this.pendingColor, - required this.highlightedColor, - required this.semanticsLabel, - required this.liftEnabled, - }); - - @override - State<_SweepingTimedLyricText> createState() => - _SweepingTimedLyricTextState(); -} - -class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { - TextPainter? _pendingPainter; - TextPainter? _highlightedPainter; - String? _cachedText; - TextStyle? _cachedStyle; - TextAlign? _cachedAlignment; - TextDirection? _cachedDirection; - TextScaler? _cachedScaler; - Locale? _cachedLocale; - Color? _cachedPendingColor; - Color? _cachedHighlightedColor; - - void _ensurePainters( - String text, - TextDirection textDirection, - TextScaler textScaler, - Locale? locale, - ) { - if (_cachedText == text && - _cachedStyle == widget.style && - _cachedAlignment == widget.textAlign && - _cachedDirection == textDirection && - _cachedScaler == textScaler && - _cachedLocale == locale && - _cachedPendingColor == widget.pendingColor && - _cachedHighlightedColor == widget.highlightedColor) { - return; - } - - _pendingPainter?.dispose(); - _highlightedPainter?.dispose(); - _pendingPainter = TextPainter( - text: TextSpan( - text: text, - style: widget.style.copyWith(color: widget.pendingColor), - ), - textAlign: widget.textAlign, - textDirection: textDirection, - textScaler: textScaler, - locale: locale, - ); - _highlightedPainter = TextPainter( - text: TextSpan( - text: text, - style: widget.style.copyWith(color: widget.highlightedColor), - ), - textAlign: widget.textAlign, - textDirection: textDirection, - textScaler: textScaler, - locale: locale, - ); - _cachedText = text; - _cachedStyle = widget.style; - _cachedAlignment = widget.textAlign; - _cachedDirection = textDirection; - _cachedScaler = textScaler; - _cachedLocale = locale; - _cachedPendingColor = widget.pendingColor; - _cachedHighlightedColor = widget.highlightedColor; - } - - @override - void dispose() { - _pendingPainter?.dispose(); - _highlightedPainter?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final textDirection = Directionality.of(context); - final textScaler = MediaQuery.textScalerOf(context); - final locale = Localizations.maybeLocaleOf(context); - final text = widget.segments.join(); - _ensurePainters(text, textDirection, textScaler, locale); - - return LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.hasBoundedWidth - ? constraints.maxWidth - : double.infinity; - final pendingPainter = _pendingPainter! - ..layout( - minWidth: constraints.hasBoundedWidth ? maxWidth : 0, - maxWidth: maxWidth, - ); - final highlightedPainter = _highlightedPainter! - ..layout( - minWidth: constraints.hasBoundedWidth ? maxWidth : 0, - maxWidth: maxWidth, - ); - final width = constraints.hasBoundedWidth - ? constraints.maxWidth - : pendingPainter.width; - final height = pendingPainter.height; - final segmentBoxes = >[]; - var segmentOffset = 0; - for (final segment in widget.segments) { - final segmentEnd = segmentOffset + segment.length; - // Select the whole timed word, keeping paragraph shaping and wrapping - // intact. All of its text runs share the same vertical movement. - final boxes = highlightedPainter - .getBoxesForSelection( - TextSelection( - baseOffset: segmentOffset, - extentOffset: segmentEnd, - ), - boxHeightStyle: BoxHeightStyle.max, - ) - .map((box) => box.toRect()) - .toList(); - boxes.sort((a, b) { - final row = a.top.compareTo(b.top); - return row == 0 ? a.left.compareTo(b.left) : row; - }); - segmentBoxes.add(boxes); - segmentOffset = segmentEnd; - } - - return Semantics( - label: widget.semanticsLabel, - child: CustomPaint( - size: Size(width, height), - painter: _TimedLyricSweepPainter( - segmentBoxes: segmentBoxes, - starts: widget.starts, - ends: widget.ends, - currentPosition: widget.currentPosition, - repaint: widget.repaint, - pendingPainter: pendingPainter, - highlightedPainter: highlightedPainter, - highlightLift: widget.liftEnabled - ? (textScaler.scale(widget.style.fontSize ?? 24) * 0.05) - .clamp(0.0, 2.0) - : 0, - ), - ), - ); - }, - ); - } -} - -class _TimedLyricSweepPainter extends CustomPainter { - final List> segmentBoxes; - final List starts; - final List ends; - final Duration Function() currentPosition; - final TextPainter pendingPainter; - final TextPainter highlightedPainter; - final double highlightLift; - - _TimedLyricSweepPainter({ - required this.segmentBoxes, - required this.starts, - required this.ends, - required this.currentPosition, - required Listenable repaint, - required this.pendingPainter, - required this.highlightedPainter, - required this.highlightLift, - }) : super(repaint: repaint); - - @override - void paint(Canvas canvas, Size size) { - final pendingPaths = {}; - final completedPaths = {}; - final partialBoxes = <(Rect, double, double, double)>[]; - final position = currentPosition(); - for (var index = 0; index < segmentBoxes.length; index++) { - final timed = index < starts.length && index < ends.length; - final value = timed - ? syncedLyricSegmentProgress( - position: position, - start: starts[index], - end: ends[index], - ) - : 0.0; - final boxes = segmentBoxes[index]; - final width = boxes.fold(0, (sum, box) => sum + box.width); - final lift = highlightLift > 0 && timed - ? highlightLift * - syncedLyricSegmentLift( - position: position, - start: starts[index], - end: ends[index], - ) - : 0.0; - var consumed = 0.0; - for (final box in boxes) { - if (box.width <= 0) continue; - if (highlightLift > 0) { - pendingPaths.putIfAbsent(lift, Path.new).addRect(box); - } - // Keep the color sweep continuous across wrapping and font fallback, - // independently of the movement shared by the whole word. - final revealWidth = width * value - consumed; - final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp( - 3.0, - 10.0, - ); - if (value > 0 && revealWidth >= box.width) { - completedPaths.putIfAbsent(lift, Path.new).addRect(box); - } else if (value > 0 && revealWidth > -feather) { - partialBoxes.add((box, revealWidth / box.width, lift, feather)); - } - consumed += box.width; - } - } - - // Move both colors together, so a raised highlight never leaves a dim - // duplicate behind. Most words share the resting or completed position. - if (highlightLift > 0) { - for (final entry in pendingPaths.entries) { - _paintLiftedText(canvas, pendingPainter, entry.value, entry.key); - } - } else { - pendingPainter.paint(canvas, Offset.zero); - } - for (final entry in completedPaths.entries) { - _paintLiftedText(canvas, highlightedPainter, entry.value, entry.key); - } - - for (final (box, value, lift, feather) in partialBoxes) { - // Feather the leading edge so the highlight flows through each letter - // while the word rises as a single unit. - final boundary = box.left + box.width * value; - final revealRight = (boundary + feather).clamp(box.left, box.right); - final revealRect = Rect.fromLTRB( - box.left, - box.top, - revealRight, - box.bottom, - ); - canvas.save(); - canvas.translate(0, -lift); - canvas.clipRect(revealRect); - canvas.saveLayer(revealRect, Paint()); - highlightedPainter.paint(canvas, Offset.zero); - final mask = Paint() - ..blendMode = BlendMode.dstIn - ..shader = - LinearGradient( - colors: const [Colors.white, Colors.transparent], - ).createShader( - Rect.fromLTRB(boundary, box.top, boundary + feather, box.bottom), - ); - canvas.drawRect(revealRect, mask); - canvas.restore(); - canvas.restore(); - } - } - - void _paintLiftedText( - Canvas canvas, - TextPainter painter, - Path clip, - double lift, - ) { - canvas.save(); - canvas.translate(0, -lift); - canvas.clipPath(clip, doAntiAlias: false); - painter.paint(canvas, Offset.zero); - canvas.restore(); - } - - @override - bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) { - return oldDelegate.segmentBoxes != segmentBoxes || - oldDelegate.starts != starts || - oldDelegate.ends != ends || - oldDelegate.currentPosition != currentPosition || - oldDelegate.highlightLift != highlightLift || - oldDelegate.pendingPainter != pendingPainter || - oldDelegate.highlightedPainter != highlightedPainter; - } -} - class _MetadataList extends StatelessWidget { final Map meta; final ColorScheme colorScheme; diff --git a/lib/widgets/player_lyrics.dart b/lib/widgets/player_lyrics.dart new file mode 100644 index 00000000..c82283fb --- /dev/null +++ b/lib/widgets/player_lyrics.dart @@ -0,0 +1,1687 @@ +import 'dart:async'; +import 'dart:ui' show BoxHeightStyle, ImageFilter; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show ValueListenable, listEquals; +import 'package:flutter/rendering.dart' + show OverflowBoxFit, RenderAbstractViewport, ScrollDirection; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/utils/lyrics_timeline.dart'; +import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; +import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart'; +import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart'; +import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart'; +import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; +import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; + +const _mornyeLyricFontSize = 34.0; +const mornyeLyricsContentInset = 28.0 + MornyePlayerSlider.horizontalInset; + +class SyncedLyricsView extends ConsumerStatefulWidget { + final ParsedLyrics lyrics; + final ValueListenable seekPreview; + final LyricsCredits? credits; + final ColorScheme colorScheme; + final bool isActive; + final bool showPronunciation; + final bool showTranslation; + + const SyncedLyricsView({ + super.key, + required this.lyrics, + required this.seekPreview, + this.credits, + required this.colorScheme, + required this.isActive, + required this.showPronunciation, + required this.showTranslation, + }); + + @override + ConsumerState createState() => _SyncedLyricsViewState(); +} + +class _SyncedLyricsViewState extends ConsumerState + with TickerProviderStateMixin { + final ScrollController _scroll = ScrollController(); + ProviderSubscription? _positionSubscription; + ProviderSubscription? _playingSubscription; + ProviderSubscription? _loadingSubscription; + Timer? _lineBoundaryTimer; + Timer? _userScrollIdleTimer; + late List _lines; + late LyricDisplayLayout _displayLayout; + late List _lineKeys; + int _active = -1; + Set _activeLines = {}; + Duration _activeTransitionPosition = Duration.zero; + bool _playing = false; + bool _loading = false; + bool _hasStarted = false; + bool _userScrolling = false; + static const double _estimatedLyricExtent = 64; + List? _lineExtents; + List<(double, double, double)> _lineMeasurements = []; + List _pronunciationLayouts = []; + Object? _lineLayoutKey; + double? _viewportHeight; + Offset? _layoutVisibility; + late final AnimationController _rowReveal = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + )..addListener(_revealTick); + List _revealFrom = []; + List _revealTo = []; + List? _layoutRowVisibility; + List? _targetLineExtents; + bool _preserveRevealAnchor = false; + late final AnimationController _scrollMotion = AnimationController( + vsync: this, + duration: LyricScrollMotion.duration, + value: 1, + ); + double _scrollMotionDistance = 0; + int _scrollMotionFocus = 0; + + void _stopScrollMotion() { + _scrollMotion.stop(); + _scrollMotion.value = 1; + } + + void _startScrollMotion(int row, double distance) { + if (distance.abs() < 0.5 || + widget.seekPreview.value != null || + MediaQuery.disableAnimationsOf(context)) { + _stopScrollMotion(); + return; + } + setState(() { + _scrollMotionDistance = distance; + _scrollMotionFocus = row; + }); + _scrollMotion.forward(from: 0); + } + + void _revealTick() { + if (mounted) setState(() {}); + } + + List get _rowVisibility { + final progress = Curves.easeInOutCubic.transform(_rowReveal.value); + return List.generate( + _lines.length, + (index) => + _revealFrom[index] + + (_revealTo[index] - _revealFrom[index]) * progress, + ); + } + + void _updateRowVisibility({bool immediate = false, bool sameFocus = false}) { + _preserveRevealAnchor = sameFocus; + final target = List.generate(_lines.length, (index) { + final line = _lines[index]; + if (line.text.isEmpty) return _activeLines.contains(index) ? 1.0 : 0.0; + if (line.isBackground) return index <= _active ? 1.0 : 0.0; + return 1.0; + }); + if (immediate || MediaQuery.disableAnimationsOf(context)) { + _rowReveal.stop(); + _revealFrom = target; + _revealTo = target; + return; + } + if (listEquals(target, _revealTo)) return; + _revealFrom = _rowVisibility; + _revealTo = target; + _rowReveal.forward(from: 0); + } + + // The header stays fixed when controls collapse. Anchor lyrics to it, not + // to a fraction of the growing viewport, including during the transition. + double get _focusInset => + (MediaQuery.sizeOf(context).height * 0.06).clamp(16.0, 48.0); + + @override + void initState() { + super.initState(); + _resetLineKeys(); + widget.seekPreview.addListener(_previewChanged); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Appearance can rebuild when transport controls fade (for example with + // custom glass clarity). Keep the existing playback subscription and the + // user's scroll ownership; only new lyrics/page activation should reset it. + if (_positionSubscription == null && widget.isActive) { + _syncPositionSubscription(); + } + } + + @override + void didUpdateWidget(covariant SyncedLyricsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seekPreview != widget.seekPreview) { + oldWidget.seekPreview.removeListener(_previewChanged); + widget.seekPreview.addListener(_previewChanged); + } + if (oldWidget.lyrics != widget.lyrics) { + _resetLineKeys(); + } + if (oldWidget.showPronunciation != widget.showPronunciation || + oldWidget.showTranslation != widget.showTranslation) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_userScrolling && _scroll.hasClients) { + // Cancel a pending line-scroll once, then let layout preserve the + // current anchor throughout the supplement animation. + _scroll.jumpTo(_scroll.offset); + } + }); + } + if (oldWidget.isActive != widget.isActive || + oldWidget.lyrics != widget.lyrics) { + _syncPositionSubscription(); + } + } + + void _resetLineKeys() { + _stopScrollMotion(); + _hasStarted = false; + _lineExtents = null; + _lineLayoutKey = null; + _lines = lyricsTimelineWithGaps(widget.lyrics.lines); + _rowReveal.stop(); + _revealFrom = _revealTo = [ + for (final line in _lines) + if (line.isBackground || line.text.isEmpty) 0.0 else 1.0, + ]; + _layoutRowVisibility = null; + _targetLineExtents = null; + _displayLayout = LyricDisplayLayout(_lines); + _lineKeys = List.generate( + _lines.length, + (index) => GlobalKey(debugLabel: 'lyric-line-$index'), + growable: false, + ); + } + + void _syncPositionSubscription() { + _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _lineBoundaryTimer?.cancel(); + _positionSubscription = null; + _playingSubscription = null; + _loadingSubscription = null; + if (!widget.isActive) { + _stopScrollMotion(); + return; + } + _userScrollIdleTimer?.cancel(); + _userScrolling = false; + + final position = _displayPosition; + _playing = ref.read(playbackPlayingProvider); + _loading = ref.read(playbackLoadingProvider); + _active = _activeIndexAt(position); + _activeLines = activeLyricIndices(_lines, position, _active); + _updateRowVisibility(immediate: true); + _activeTransitionPosition = position; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_maybeAutoScroll(_active, immediate: true)); + }); + _scheduleNextLine(position); + _positionSubscription = ref.listenManual( + playbackPositionProvider, + (previous, next) { + if (widget.seekPreview.value != null) return; + final active = _activeIndexAt(next); + _setActiveLine(active, position: next); + _scheduleNextLine(next); + }, + ); + _playingSubscription = ref.listenManual(playbackPlayingProvider, ( + previous, + next, + ) { + _playing = next; + final position = _displayPosition; + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); + }); + _loadingSubscription = ref.listenManual(playbackLoadingProvider, ( + previous, + next, + ) { + _loading = next; + final position = _displayPosition; + _setActiveLine(_activeIndexAt(position), position: position); + _scheduleNextLine(position); + }); + } + + Duration get _displayPosition => + widget.seekPreview.value ?? ref.read(playbackPositionProvider); + + void _previewChanged() { + if (!mounted || !widget.isActive) return; + final position = _displayPosition; + final wasUserScrolling = _userScrolling; + _userScrolling = false; + _userScrollIdleTimer?.cancel(); + final active = _activeIndexAt(position); + if (active == _active && (wasUserScrolling || active < 0)) { + unawaited(_maybeAutoScroll(active)); + } + _setActiveLine(active, position: position); + _scheduleNextLine(position); + } + + int _activeIndexAt(Duration position) { + // Scrubbing is an explicit preview, even at zero or while audio buffers. + if (widget.seekPreview.value != null) { + return LyricsParser.activeIndex(_lines, position); + } + if (context.isMornye) { + // Read one transport snapshot: playing/loading derived providers can + // notify separately during the same playback event. + final playback = ref.read(playbackStateProvider).value; + if (playback?.processingState == AudioProcessingState.loading || + playback?.processingState == AudioProcessingState.buffering) { + return -1; + } + _hasStarted = + _hasStarted || playback?.playing == true || position > Duration.zero; + if (!_hasStarted) return -1; + } + return LyricsParser.activeIndex(_lines, position); + } + + void _setActiveLine(int active, {required Duration position}) { + if (!mounted) return; + final activeLines = activeLyricIndices(_lines, position, active); + final indexChanged = active != _active; + final previousFocus = _active < 0 + ? -1 + : _displayLayout.focusForLine[_active]; + if (!indexChanged && + activeLines.length == _activeLines.length && + activeLines.containsAll(_activeLines)) { + return; + } + setState(() { + _active = active; + _activeLines = activeLines; + _activeTransitionPosition = position; + _updateRowVisibility( + sameFocus: + active >= 0 && _displayLayout.focusForLine[active] == previousFocus, + ); + }); + if (!indexChanged || + (active >= 0 && _displayLayout.focusForLine[active] == previousFocus)) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && active == _active) unawaited(_maybeAutoScroll(active)); + }); + } + + void _scheduleNextLine(Duration position) { + _lineBoundaryTimer?.cancel(); + if (!widget.isActive || + !_playing || + _loading || + widget.seekPreview.value != null) { + return; + } + + final lines = _lines; + final dueIndex = syncedLyricsDueLineIndex( + lineStarts: lines.map((line) => line.time).toList(growable: false), + currentIndex: _active, + position: position, + ); + _setActiveLine(dueIndex, position: position); + + final nextIndex = dueIndex + 1; + Duration? nextBoundary = nextIndex < lines.length + ? lines[nextIndex].time + : null; + for (final index in _activeLines) { + final end = lines[index].end; + if ((index != dueIndex || + lines[index].voice != null || + lines[index].isBackground) && + end != null && + end > position && + (nextBoundary == null || end < nextBoundary)) { + nextBoundary = end; + } + } + if (nextBoundary == null) return; + final boundary = nextBoundary; + _lineBoundaryTimer = Timer(boundary - position, () { + if (!mounted || !widget.isActive || !_playing || _loading) return; + _scheduleNextLine(boundary); + }); + } + + @override + void dispose() { + widget.seekPreview.removeListener(_previewChanged); + _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _lineBoundaryTimer?.cancel(); + _userScrollIdleTimer?.cancel(); + _scroll.dispose(); + _rowReveal.dispose(); + _scrollMotion.dispose(); + super.dispose(); + } + + void _measureMornyeLines(double width) { + final style = _mornyeLyricStyle(context); + final scaler = MediaQuery.textScalerOf(context); + final direction = Directionality.of(context); + final locale = Localizations.maybeLocaleOf(context); + final key = (width, style, scaler, direction, locale); + if (_lineLayoutKey == key) return; + _lineLayoutKey = key; + final painter = TextPainter( + textDirection: direction, + textScaler: scaler, + locale: locale, + ); + final measurements = <(double, double, double)>[]; + final layouts = List.filled(_lines.length, null); + for (final index in _displayLayout.lineOrder) { + final line = _lines[index]; + if (line.text.isEmpty) { + measurements.add((56, 0, 0)); + continue; + } + painter.text = TextSpan( + text: line.text, + style: _mornyeLyricStyle(context, background: line.isBackground), + ); + painter.layout(maxWidth: width); + final padding = _linePadding(index).vertical; + var height = painter.height + padding; + var pronunciationHeight = 0.0; + var translationHeight = 0.0; + LyricPronunciationLayout? pronunciationLayout; + for (final (text, style, _, translation) in _lyricSupplements( + context, + line, + )) { + painter.text = TextSpan(text: text, style: style); + painter.layout(maxWidth: width); + if (translation) { + translationHeight = 6 + painter.height; + } else { + pronunciationHeight = 6 + painter.height; + final aligned = LyricPronunciationLayout.measure( + line: line, + primaryStyle: _mornyeLyricStyle( + context, + background: line.isBackground, + ), + pronunciationStyle: style, + maxWidth: width, + textScaler: scaler, + textDirection: direction, + locale: locale, + ); + if (aligned != null) { + pronunciationLayout = aligned; + height = aligned.primaryHeight + padding; + pronunciationHeight = aligned.pronunciationHeight; + } + } + } + measurements.add((height, pronunciationHeight, translationHeight)); + layouts[index] = pronunciationLayout; + } + _lineMeasurements = measurements; + _pronunciationLayouts = layouts; + painter.dispose(); + } + + Future _maybeAutoScroll(int index, {bool immediate = false}) async { + // A short intro may have no countdown row. Still return to the first + // upcoming lyric when the user scrubs back before any vocals. + if (index < 0 && widget.seekPreview.value != null) index = 0; + if (_userScrolling || + index < 0 || + index >= _lines.length || + !_scroll.hasClients) { + return; + } + index = _displayLayout.focusForLine[index]; + final row = _displayLayout.rowForLine[index]; + final duration = immediate || MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); + if (duration == Duration.zero) _stopScrollMotion(); + final extents = _targetLineExtents; + if (context.isMornye && extents != null && row < extents.length) { + final position = _scroll.position; + final target = extents.take(row).fold(0.0, (sum, extent) => sum + extent); + final offset = target.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (duration == Duration.zero) { + _scroll.jumpTo(offset); + } else { + _startScrollMotion(row, offset - position.pixels); + await _scroll.animateTo( + offset, + duration: duration, + curve: Curves.easeOutCubic, + ); + } + return; + } + if (index < _lineKeys.length) { + final lineContext = _lineKeys[index].currentContext; + if (lineContext != null) { + final object = lineContext.findRenderObject(); + final viewport = object == null + ? null + : RenderAbstractViewport.maybeOf(object); + if (duration != Duration.zero && viewport != null) { + _startScrollMotion( + row, + viewport.getOffsetToReveal(object!, 0.5).offset - _scroll.offset, + ); + } + await Scrollable.ensureVisible( + lineContext, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: duration, + curve: Curves.easeOutCubic, + ); + return; + } + } + + final position = _scroll.position; + final target = syncedLyricsEstimatedOffset( + index: row, + estimatedLineExtent: _estimatedLyricExtent, + ); + final clamped = target.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (duration == Duration.zero) { + _scroll.jumpTo(clamped.toDouble()); + } else { + _startScrollMotion(row, clamped - position.pixels); + await _scroll.animateTo( + clamped.toDouble(), + duration: duration, + curve: Curves.easeOutCubic, + ); + } + if (!mounted || + _userScrolling || + (_active >= 0 && index != _displayLayout.focusForLine[_active]) || + index >= _lineKeys.length) { + return; + } + final lineContext = _lineKeys[index].currentContext; + if (lineContext != null && lineContext.mounted) { + await Scrollable.ensureVisible( + lineContext, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + duration: duration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) => TweenAnimationBuilder( + tween: Tween( + end: Offset( + widget.showPronunciation ? 1 : 0, + widget.showTranslation ? 1 : 0, + ), + ), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 420), + curve: Curves.easeInOutCubic, + builder: (context, visibility, _) => _buildLyrics(context, visibility), + ); + + Widget _buildLyrics(BuildContext context, Offset visibility) { + final lines = _lines; + final active = _active; + final rowVisibility = _rowVisibility; + final loading = ref.watch(playbackLoadingProvider); + final mornye = context.isMornye; + final highContrast = MediaQuery.highContrastOf(context); + final blurLyrics = + mornye && + !highContrast && + // Every defocused line is filtered again on each playback frame. + ref.watch(mornyeLiquidGlassProvider); + final motion = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 280); + + return NotificationListener( + onNotification: (notification) { + if (notification.depth != 0 || + notification.metrics.axis != Axis.vertical) { + return false; + } + final dragging = + (notification is ScrollStartNotification && + notification.dragDetails != null) || + (notification is ScrollUpdateNotification && + notification.dragDetails != null) || + (notification is UserScrollNotification && + notification.direction != ScrollDirection.idle); + if (dragging) { + _stopScrollMotion(); + _userScrolling = true; + _userScrollIdleTimer?.cancel(); + } else if (notification is ScrollEndNotification && _userScrolling) { + // Start the return-to-playback timeout only after the drag and its + // momentum finish. A direction notification is not an idle event: + // a long drag can keep that direction while controls fade in/out. + _userScrollIdleTimer?.cancel(); + _userScrollIdleTimer = Timer(const Duration(seconds: 4), () { + if (!mounted || !widget.isActive) return; + _userScrolling = false; + unawaited(_maybeAutoScroll(_active)); + }); + } + return false; + }, + child: LayoutBuilder( + builder: (context, constraints) { + final inset = mornye ? mornyeLyricsContentInset : 24.0; + final contentWidth = (constraints.maxWidth - inset * 2).clamp( + 0.0, + double.infinity, + ); + // Lay out at the existing lyric width, then fit the whole line to + // the timeline. Scaling text, phrase gaps and supplements together + // preserves wrapping instead of pushing extra words onto a new row. + final layoutWidth = (constraints.maxWidth - 48).clamp( + 0.0, + double.infinity, + ); + final lyricScale = mornye && layoutWidth > 0 + ? contentWidth / layoutWidth + : 1.0; + if (mornye) { + final previousExtents = _lineExtents; + _measureMornyeLines(layoutWidth); + // Reuse text measurements; only interpolate row heights as the + // supplements fade. Scrolling follows the same animation clock. + final fullExtents = List.generate(_lineMeasurements.length, (row) { + final (primary, pronunciation, translation) = + _lineMeasurements[row]; + final index = _displayLayout.lineOrder[row]; + if (_lines[index].text.isEmpty) return primary; + final padding = _linePadding(index).vertical; + return padding + + (primary - + padding + + pronunciation * visibility.dx + + translation * visibility.dy) * + lyricScale; + }); + _lineExtents = List.generate(fullExtents.length, (row) { + return fullExtents[row] * + rowVisibility[_displayLayout.lineOrder[row]]; + }); + _targetLineExtents = List.generate(fullExtents.length, (row) { + return fullExtents[row] * + _revealTo[_displayLayout.lineOrder[row]]; + }); + if (previousExtents != null && + ((_layoutVisibility != null && + _layoutVisibility != visibility) || + (_preserveRevealAnchor && + _layoutRowVisibility != null && + !listEquals(_layoutRowVisibility, rowVisibility))) && + previousExtents.length == _lineExtents!.length && + _scroll.hasClients) { + var anchor = _active < 0 + ? 0 + : _displayLayout.rowForLine[_displayLayout + .focusForLine[_active]]; + if (_userScrolling) { + anchor = 0; + var extent = 0.0; + while (anchor < previousExtents.length && + extent + previousExtents[anchor] <= _scroll.offset) { + extent += previousExtents[anchor++]; + } + } + var correction = 0.0; + for (var i = 0; i < anchor; i++) { + correction += _lineExtents![i] - previousExtents[i]; + } + // Correct before the ListView lays out/paints. A post-frame + // jump leaves every painted frame one animation step behind. + if (correction != 0) _scroll.position.correctBy(correction); + } + } + _layoutVisibility = visibility; + _layoutRowVisibility = rowVisibility; + if (_viewportHeight != constraints.maxHeight) { + _viewportHeight = constraints.maxHeight; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_maybeAutoScroll(_active, immediate: true)); + } + }); + } + final topPadding = mornye + ? _focusInset + : syncedLyricsCenterPadding( + viewportDimension: constraints.maxHeight, + estimatedLineExtent: _estimatedLyricExtent, + ); + // Leave enough trailing space for the final line to reach the same + // upper focus position as every other line. + final bottomPadding = mornye && _lineExtents!.isNotEmpty + ? (constraints.maxHeight - _lineExtents!.last - topPadding).clamp( + topPadding, + double.infinity, + ) + : topPadding; + return ListView.builder( + controller: _scroll, + itemExtentBuilder: mornye + ? (index, _) => index < lines.length + ? _lineExtents![index] + : widget.credits!.heightFor(context, contentWidth) + : null, + padding: EdgeInsets.fromLTRB( + inset, + topPadding, + inset, + bottomPadding, + ), + itemCount: lines.length + (widget.credits == null ? 0 : 1), + itemBuilder: (context, row) { + if (row == lines.length) return widget.credits!; + final index = _displayLayout.lineOrder[row]; + final line = lines[index]; + final leadIndex = _displayLayout.leadForLine[index]; + final textAlign = _lyricTextAlign(context, lines[leadIndex]); + final reveal = rowVisibility[index]; + if (reveal == 0) return SizedBox.shrink(key: _lineKeys[index]); + final isActive = _activeLines.contains(index); + final isPast = index < active; + + final color = mornye + ? Colors.white + : isActive + ? widget.colorScheme.onSurface + : isPast + ? widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.5) + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.8); + + if (line.text.isEmpty) { + return _revealRow( + index, + reveal, + textAlign, + Padding( + padding: EdgeInsets.symmetric( + vertical: context.tokens.lyricsLinePaddingV, + ), + child: SizedBox( + height: 24, + child: Align( + alignment: mornye + ? Alignment.centerLeft + : Alignment.center, + child: + isActive && + widget.isActive && + (!loading || widget.seekPreview.value != null) + ? ValueListenableBuilder( + valueListenable: widget.seekPreview, + builder: (context, preview, _) => + LyricGapIndicator( + key: ValueKey(line.time), + start: line.time, + end: line.end!, + color: color, + position: preview, + ), + ) + : null, + ), + ), + ), + ); + } + + final timed = + isActive && + (line.hasWordTiming || line.romanizationWords.isNotEmpty); + Widget content; + if (timed) { + content = _WordHighlightedLyricLine( + line: line, + colorScheme: widget.colorScheme, + animate: widget.isActive, + initialPosition: _activeTransitionPosition, + seekPreview: widget.seekPreview, + supplementVisibility: visibility, + pronunciationLayout: mornye + ? _pronunciationLayouts[index] + : null, + textAlign: textAlign, + ); + } else { + content = Text( + line.text, + textAlign: textAlign, + style: + (mornye || isActive + ? Theme.of(context).textTheme.headlineSmall + : Theme.of(context).textTheme.titleLarge) + ?.copyWith( + height: context.tokens.lyricsLineHeight, + fontSize: line.isBackground + ? (mornye ? _mornyeLyricFontSize * 0.68 : 18) + : mornye + ? _mornyeLyricFontSize + : null, + fontWeight: mornye || isActive + ? FontWeight.bold + : FontWeight.w500, + color: color, + ), + ); + content = _withLyricSupplements( + context, + line, + content, + color, + visibility: visibility, + pronunciationLayout: mornye + ? _pronunciationLayouts[index] + : null, + textAlign: textAlign, + ); + } + content = AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + reverseDuration: const Duration(milliseconds: 260), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: KeyedSubtree( + key: ValueKey(timed), + child: SizedBox(width: double.infinity, child: content), + ), + ); + if (mornye) { + final activeLead = active < 0 + ? -1 + : _displayLayout.focusForLine[active]; + final distance = activeLead < 0 + ? row + 1 + : (_displayLayout.rowForLine[leadIndex] - + _displayLayout.rowForLine[activeLead]) + .abs(); + // Nearby lines need visible defocus at the larger lyric size; + // progressively soften lines further from the current one. + final sigma = !blurLyrics || isActive || leadIndex == activeLead + ? 0.0 + : active < 0 + ? 4.8 + : distance == 1 + ? 2.4 + : distance == 2 + ? 3.6 + : 4.8; + content = TweenAnimationBuilder( + tween: Tween(end: sigma), + duration: motion, + child: RepaintBoundary(child: content), + builder: (context, value, child) => ImageFiltered( + enabled: value > 0, + imageFilter: ImageFilter.blur(sigmaX: value, sigmaY: value), + child: child, + ), + ); + content = FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox(width: layoutWidth, child: content), + ); + } + + content = LyricScrollMotion( + progress: _scrollMotion, + distance: _scrollMotionDistance, + rowsAfterFocus: row - _scrollMotionFocus, + enabled: !isActive, + child: content, + ); + return _revealRow( + index, + reveal, + textAlign, + Padding( + padding: _linePadding(index), + child: GestureDetector( + onTap: () => + ref.read(musicPlayerControllerProvider).seek(line.time), + child: AnimatedScale( + scale: mornye || isActive ? 1.0 : 0.96, + alignment: Alignment.center, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: mornye + ? (isActive || highContrast ? 1 : 0.48) + : (isActive ? 1.0 : (isPast ? 0.55 : 0.85)), + duration: const Duration(milliseconds: 280), + child: content, + ), + ), + ), + ), + ); + }, + ); + }, + ), + ); + } + + Widget _revealRow( + int index, + double visibility, + TextAlign align, + Widget child, + ) { + return KeyedSubtree( + key: _lineKeys[index], + child: !_lines[index].isBackground && _lines[index].text.isNotEmpty + ? child + : IgnorePointer( + ignoring: visibility < 1, + child: LyricSupplementTransition( + visibility: visibility, + alignment: align == TextAlign.right + ? Alignment.topRight + : align == TextAlign.center + ? Alignment.topCenter + : Alignment.topLeft, + // A fixed-extent row is already shrinking. Measure its + // contents at full height so fitting does not scale it twice. + child: OverflowBox( + minHeight: 0, + maxHeight: double.infinity, + fit: OverflowBoxFit.deferToChild, + alignment: Alignment.topCenter, + child: child, + ), + ), + ), + ); + } + + EdgeInsets _linePadding(int index) { + final row = _displayLayout.rowForLine[index]; + final order = _displayLayout.lineOrder; + final hasBackingBelow = + row + 1 < order.length && + _lines[order[row + 1]].isBackground && + _displayLayout.leadForLine[order[row + 1]] == + _displayLayout.leadForLine[index]; + return EdgeInsets.only( + top: _lines[index].isBackground ? 6 : context.tokens.lyricsLinePaddingV, + bottom: hasBackingBelow ? 0 : context.tokens.lyricsLinePaddingV, + ); + } +} + +class LyricsCredits extends StatelessWidget { + const LyricsCredits({super.key, this.writers, this.provider}); + + final String? writers; + final String? provider; + + String _text(BuildContext context) => [ + if (writers != null) context.l10n.nowPlayingWrittenBy(writers!), + if (provider != null) context.l10n.nowPlayingLyricsProvider(provider!), + ].join('\n'); + + TextStyle _style(BuildContext context) => + Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: context.isMornye ? 17 : 14, + fontWeight: FontWeight.w600, + height: 1.4, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.48), + ); + + double heightFor(BuildContext context, double width) { + final painter = TextPainter( + text: TextSpan(text: _text(context), style: _style(context)), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: width.clamp(0, double.infinity)); + final height = painter.height + 32; + painter.dispose(); + return height; + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text(_text(context), style: _style(context)), + ); +} + +Iterable<(String, TextStyle, List, bool)> _lyricSupplements( + BuildContext context, + LyricLine line, +) sync* { + final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle(); + for (final (text, translation) in [ + (line.romanization, false), + (line.translation, true), + ]) { + if (text == null || text.trim().isEmpty) continue; + yield ( + text, + base.copyWith( + fontSize: + (context.isMornye + ? (translation ? 18.0 : 22.0) + : (translation ? 14.0 : 16.0)) * + (line.isBackground ? 0.8 : 1), + height: 1.35, + fontWeight: context.isMornye + ? (translation ? FontWeight.w600 : FontWeight.bold) + : FontWeight.w500, + ), + translation ? const [] : line.romanizationWords, + translation, + ); + } +} + +TextStyle _mornyeLyricStyle(BuildContext context, {bool background = false}) => + (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()).copyWith( + fontSize: _mornyeLyricFontSize * (background ? 0.68 : 1), + height: context.tokens.lyricsLineHeight, + fontWeight: FontWeight.bold, + ); + +TextAlign _lyricTextAlign(BuildContext context, LyricLine line) { + final voice = line.voice; + if (voice == null) { + return context.isMornye ? TextAlign.start : TextAlign.center; + } + if (voice.isGroup) return TextAlign.left; + // Provider declarations can be missing or ordered by first appearance. + // The standard V labels retain their meaning regardless of that order. + switch (voice.id.toLowerCase()) { + case 'v1': + case 'v3': + return TextAlign.left; + case 'v2': + return TextAlign.right; + } + return voice.index.isEven ? TextAlign.left : TextAlign.right; +} + +Widget _withLyricSupplements( + BuildContext context, + LyricLine line, + Widget primary, + Color color, { + required Offset visibility, + required LyricPronunciationLayout? pronunciationLayout, + required TextAlign textAlign, + Widget Function(String, List, TextStyle)? timedText, + Widget Function(String, List, TextStyle)? timedSupplementText, +}) { + if (line.romanization == null && line.translation == null) return primary; + final alignment = switch (textAlign) { + TextAlign.right => Alignment.topRight, + TextAlign.center => Alignment.topCenter, + _ => Alignment.topLeft, + }; + final supplements = _lyricSupplements(context, line).toList(); + Widget withSupplements(Widget primary, {bool aligned = false}) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + primary, + for (final (text, style, words, translation) in supplements) + if ((translation || !aligned) && + (translation ? visibility.dy : visibility.dx) > 0) + LyricSupplementTransition( + alignment: alignment, + visibility: translation ? visibility.dy : visibility.dx, + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: words.isNotEmpty && timedSupplementText != null + ? timedSupplementText(text, words, style) + : Text( + text, + textAlign: textAlign, + style: style.copyWith( + color: color.withValues(alpha: color.a * 0.8), + ), + ), + ), + ), + ], + ); + if (!context.isMornye || + line.romanizationWords.isEmpty || + line.romanization?.trim().isNotEmpty != true) { + return withSupplements(primary); + } + final primaryStyle = _mornyeLyricStyle( + context, + background: line.isBackground, + ); + final pronunciationStyle = supplements + .firstWhere((supplement) => !supplement.$4) + .$2; + final layout = pronunciationLayout; + if (layout == null) return withSupplements(primary); + return withSupplements( + AlignedLyricPronunciation( + layout: layout, + visibility: visibility.dx, + primaryStyle: primaryStyle, + pronunciationStyle: pronunciationStyle, + textAlign: textAlign, + pronunciationBuilder: timedSupplementText, + textBuilder: + timedText ?? + (text, words, style) => Text( + text, + textAlign: textAlign, + style: style.copyWith(color: color), + ), + ), + aligned: true, + ); +} + +class _WordHighlightedLyricLine extends ConsumerStatefulWidget { + final LyricLine line; + final ColorScheme colorScheme; + final bool animate; + final Duration initialPosition; + final ValueListenable seekPreview; + final Offset supplementVisibility; + final LyricPronunciationLayout? pronunciationLayout; + final TextAlign textAlign; + + const _WordHighlightedLyricLine({ + required this.line, + required this.colorScheme, + required this.animate, + required this.initialPosition, + required this.seekPreview, + required this.supplementVisibility, + required this.pronunciationLayout, + required this.textAlign, + }); + + @override + ConsumerState<_WordHighlightedLyricLine> createState() => + _WordHighlightedLyricLineState(); +} + +class _WordHighlightedLyricLineState + extends ConsumerState<_WordHighlightedLyricLine> + with SingleTickerProviderStateMixin { + late final AnimationController _animationClock; + final Stopwatch _elapsedClock = Stopwatch(); + ProviderSubscription? _positionSubscription; + ProviderSubscription? _playingSubscription; + ProviderSubscription? _loadingSubscription; + + late Duration _anchorPosition; + Duration _anchorElapsed = Duration.zero; + bool _playing = false; + bool _loading = false; + + bool get _shouldAnimate => + widget.animate && + _playing && + !_loading && + widget.seekPreview.value == null; + + Duration _positionAt({required bool advance}) { + return interpolatedSyncedLyricsPosition( + anchorPosition: _anchorPosition, + elapsedSinceAnchor: _elapsedClock.elapsed - _anchorElapsed, + isPlaying: advance, + ); + } + + @override + void initState() { + super.initState(); + _elapsedClock.start(); + _anchorElapsed = _elapsedClock.elapsed; + _anchorPosition = widget.initialPosition; + _playing = ref.read(playbackPlayingProvider); + _loading = ref.read(playbackLoadingProvider); + _animationClock = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + ); + widget.seekPreview.addListener(_previewChanged); + _positionSubscription = ref.listenManual( + playbackPositionProvider, + (previous, next) => _updateReportedPosition(next), + ); + _playingSubscription = ref.listenManual( + playbackPlayingProvider, + (previous, next) => _updateTransportState(playing: next), + ); + _loadingSubscription = ref.listenManual( + playbackLoadingProvider, + (previous, next) => _updateTransportState(loading: next), + ); + _syncAnimationClock(); + } + + @override + void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seekPreview != widget.seekPreview) { + oldWidget.seekPreview.removeListener(_previewChanged); + widget.seekPreview.addListener(_previewChanged); + _previewChanged(); + } + if (oldWidget.line != widget.line || + oldWidget.initialPosition != widget.initialPosition) { + _anchorAt(widget.initialPosition); + } + if (oldWidget.animate != widget.animate) { + _anchorAt( + _positionAt(advance: oldWidget.animate && _playing && !_loading), + ); + _syncAnimationClock(); + } + } + + Duration _currentPosition() => + widget.seekPreview.value ?? _positionAt(advance: _shouldAnimate); + + void _previewChanged() { + if (!mounted) return; + _anchorAt(widget.seekPreview.value ?? ref.read(playbackPositionProvider)); + _syncAnimationClock(); + setState(() {}); + } + + void _anchorAt(Duration position) { + _anchorPosition = position; + _anchorElapsed = _elapsedClock.elapsed; + } + + void _updateReportedPosition(Duration position) { + if (!mounted || widget.seekPreview.value != null) return; + final predicted = _currentPosition(); + _anchorAt( + _shouldAnimate + ? reconcileSyncedLyricsPosition( + predictedPosition: predicted, + reportedPosition: position, + ) + : position, + ); + setState(() {}); + } + + void _updateTransportState({bool? playing, bool? loading}) { + if (!mounted) return; + final position = _currentPosition(); + if (playing != null) _playing = playing; + if (loading != null) _loading = loading; + _anchorAt( + widget.seekPreview.value ?? + (_shouldAnimate ? position : ref.read(playbackPositionProvider)), + ); + _syncAnimationClock(); + setState(() {}); + } + + void _syncAnimationClock() { + if (_shouldAnimate) { + if (!_animationClock.isAnimating) { + _animationClock.repeat(); + } + } else { + _animationClock.stop(); + } + } + + Duration _segmentEnd(List words, int index) { + final word = words[index]; + final start = word.time; + if (word.end != null && word.end! >= start) return word.end!; + if (index + 1 < words.length) { + final next = words[index + 1].time; + if (next > start) return next; + } + final lineEnd = widget.line.end; + if (lineEnd != null && lineEnd > start) return lineEnd; + return start + const Duration(milliseconds: 650); + } + + @override + void dispose() { + widget.seekPreview.removeListener(_previewChanged); + _positionSubscription?.close(); + _playingSubscription?.close(); + _loadingSubscription?.close(); + _animationClock.dispose(); + _elapsedClock.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final style = context.isMornye + ? _mornyeLyricStyle(context, background: widget.line.isBackground) + : (Theme.of(context).textTheme.headlineSmall ?? const TextStyle()) + .copyWith( + fontSize: widget.line.isBackground ? 18 : null, + height: context.tokens.lyricsLineHeight, + fontWeight: FontWeight.bold, + ); + final primary = widget.line.hasWordTiming + ? _buildTimedText(widget.line.text, widget.line.words, style) + : Text( + widget.line.text, + textAlign: widget.textAlign, + style: style.copyWith(color: widget.colorScheme.onSurface), + ); + // Both scripts share this state's position interpolation and animation + // clock, including pause, seek and track changes. + return _withLyricSupplements( + context, + widget.line, + primary, + widget.colorScheme.onSurface, + timedText: _buildTimedText, + timedSupplementText: (text, words, style) => + _buildTimedText(text, words, style, lift: false), + visibility: widget.supplementVisibility, + pronunciationLayout: widget.pronunciationLayout, + textAlign: widget.textAlign, + ); + } + + Widget _buildTimedText( + String text, + List words, + TextStyle style, { + bool lift = true, + }) { + final highlightedColor = widget.colorScheme.onSurface; + final mornye = context.isMornye; + final pendingColor = mornye + ? Colors.white.withValues(alpha: 0.4) + : widget.colorScheme.onSurfaceVariant.withValues(alpha: 0.6); + return _SweepingTimedLyricText( + segments: [for (final word in words) word.text], + starts: [for (final word in words) word.time], + ends: [ + for (var index = 0; index < words.length; index++) + _segmentEnd(words, index), + ], + currentPosition: _currentPosition, + repaint: _animationClock, + style: style, + textAlign: widget.textAlign, + pendingColor: pendingColor, + highlightedColor: highlightedColor, + semanticsLabel: text, + liftEnabled: lift && mornye && !MediaQuery.disableAnimationsOf(context), + ); + } +} + +class _SweepingTimedLyricText extends StatefulWidget { + final List segments; + final List starts; + final List ends; + final Duration Function() currentPosition; + final Listenable repaint; + final TextStyle style; + final TextAlign textAlign; + final Color pendingColor; + final Color highlightedColor; + final String semanticsLabel; + final bool liftEnabled; + + const _SweepingTimedLyricText({ + required this.segments, + required this.starts, + required this.ends, + required this.currentPosition, + required this.repaint, + required this.style, + required this.textAlign, + required this.pendingColor, + required this.highlightedColor, + required this.semanticsLabel, + required this.liftEnabled, + }); + + @override + State<_SweepingTimedLyricText> createState() => + _SweepingTimedLyricTextState(); +} + +class _SweepingTimedLyricTextState extends State<_SweepingTimedLyricText> { + TextPainter? _pendingPainter; + TextPainter? _highlightedPainter; + String? _cachedText; + TextStyle? _cachedStyle; + TextAlign? _cachedAlignment; + TextDirection? _cachedDirection; + TextScaler? _cachedScaler; + Locale? _cachedLocale; + Color? _cachedPendingColor; + Color? _cachedHighlightedColor; + + void _ensurePainters( + String text, + TextDirection textDirection, + TextScaler textScaler, + Locale? locale, + ) { + if (_cachedText == text && + _cachedStyle == widget.style && + _cachedAlignment == widget.textAlign && + _cachedDirection == textDirection && + _cachedScaler == textScaler && + _cachedLocale == locale && + _cachedPendingColor == widget.pendingColor && + _cachedHighlightedColor == widget.highlightedColor) { + return; + } + + _pendingPainter?.dispose(); + _highlightedPainter?.dispose(); + _pendingPainter = TextPainter( + text: TextSpan( + text: text, + style: widget.style.copyWith(color: widget.pendingColor), + ), + textAlign: widget.textAlign, + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + ); + _highlightedPainter = TextPainter( + text: TextSpan( + text: text, + style: widget.style.copyWith(color: widget.highlightedColor), + ), + textAlign: widget.textAlign, + textDirection: textDirection, + textScaler: textScaler, + locale: locale, + ); + _cachedText = text; + _cachedStyle = widget.style; + _cachedAlignment = widget.textAlign; + _cachedDirection = textDirection; + _cachedScaler = textScaler; + _cachedLocale = locale; + _cachedPendingColor = widget.pendingColor; + _cachedHighlightedColor = widget.highlightedColor; + } + + @override + void dispose() { + _pendingPainter?.dispose(); + _highlightedPainter?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + final textScaler = MediaQuery.textScalerOf(context); + final locale = Localizations.maybeLocaleOf(context); + final text = widget.segments.join(); + _ensurePainters(text, textDirection, textScaler, locale); + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : double.infinity; + final pendingPainter = _pendingPainter! + ..layout( + minWidth: constraints.hasBoundedWidth ? maxWidth : 0, + maxWidth: maxWidth, + ); + final highlightedPainter = _highlightedPainter! + ..layout( + minWidth: constraints.hasBoundedWidth ? maxWidth : 0, + maxWidth: maxWidth, + ); + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : pendingPainter.width; + final height = pendingPainter.height; + final segmentBoxes = >[]; + var segmentOffset = 0; + for (final segment in widget.segments) { + final segmentEnd = segmentOffset + segment.length; + // Select the whole timed word, keeping paragraph shaping and wrapping + // intact. All of its text runs share the same vertical movement. + final boxes = highlightedPainter + .getBoxesForSelection( + TextSelection( + baseOffset: segmentOffset, + extentOffset: segmentEnd, + ), + boxHeightStyle: BoxHeightStyle.max, + ) + .map((box) => box.toRect()) + .toList(); + boxes.sort((a, b) { + final row = a.top.compareTo(b.top); + return row == 0 ? a.left.compareTo(b.left) : row; + }); + segmentBoxes.add(boxes); + segmentOffset = segmentEnd; + } + + return Semantics( + label: widget.semanticsLabel, + child: CustomPaint( + size: Size(width, height), + painter: _TimedLyricSweepPainter( + segmentBoxes: segmentBoxes, + starts: widget.starts, + ends: widget.ends, + currentPosition: widget.currentPosition, + repaint: widget.repaint, + pendingPainter: pendingPainter, + highlightedPainter: highlightedPainter, + highlightLift: widget.liftEnabled + ? (textScaler.scale(widget.style.fontSize ?? 24) * 0.05) + .clamp(0.0, 2.0) + : 0, + ), + ), + ); + }, + ); + } +} + +class _TimedLyricSweepPainter extends CustomPainter { + final List> segmentBoxes; + final List starts; + final List ends; + final Duration Function() currentPosition; + final TextPainter pendingPainter; + final TextPainter highlightedPainter; + final double highlightLift; + + _TimedLyricSweepPainter({ + required this.segmentBoxes, + required this.starts, + required this.ends, + required this.currentPosition, + required Listenable repaint, + required this.pendingPainter, + required this.highlightedPainter, + required this.highlightLift, + }) : super(repaint: repaint); + + @override + void paint(Canvas canvas, Size size) { + final pendingPaths = {}; + final completedPaths = {}; + final partialBoxes = <(Rect, double, double, double)>[]; + final position = currentPosition(); + for (var index = 0; index < segmentBoxes.length; index++) { + final timed = index < starts.length && index < ends.length; + final value = timed + ? syncedLyricSegmentProgress( + position: position, + start: starts[index], + end: ends[index], + ) + : 0.0; + final boxes = segmentBoxes[index]; + final width = boxes.fold(0, (sum, box) => sum + box.width); + final lift = highlightLift > 0 && timed + ? highlightLift * + syncedLyricSegmentLift( + position: position, + start: starts[index], + end: ends[index], + ) + : 0.0; + var consumed = 0.0; + for (final box in boxes) { + if (box.width <= 0) continue; + if (highlightLift > 0) { + pendingPaths.putIfAbsent(lift, Path.new).addRect(box); + } + // Keep the color sweep continuous across wrapping and font fallback, + // independently of the movement shared by the whole word. + final revealWidth = width * value - consumed; + final feather = ((highlightLift > 0 ? width : box.width) * 0.18).clamp( + 3.0, + 10.0, + ); + if (value > 0 && revealWidth >= box.width) { + completedPaths.putIfAbsent(lift, Path.new).addRect(box); + } else if (value > 0 && revealWidth > -feather) { + partialBoxes.add((box, revealWidth / box.width, lift, feather)); + } + consumed += box.width; + } + } + + // Move both colors together, so a raised highlight never leaves a dim + // duplicate behind. Most words share the resting or completed position. + if (highlightLift > 0) { + for (final entry in pendingPaths.entries) { + _paintLiftedText(canvas, pendingPainter, entry.value, entry.key); + } + } else { + pendingPainter.paint(canvas, Offset.zero); + } + for (final entry in completedPaths.entries) { + _paintLiftedText(canvas, highlightedPainter, entry.value, entry.key); + } + + for (final (box, value, lift, feather) in partialBoxes) { + // Feather the leading edge so the highlight flows through each letter + // while the word rises as a single unit. + final boundary = box.left + box.width * value; + final revealRight = (boundary + feather).clamp(box.left, box.right); + final revealRect = Rect.fromLTRB( + box.left, + box.top, + revealRight, + box.bottom, + ); + canvas.save(); + canvas.translate(0, -lift); + canvas.clipRect(revealRect); + canvas.saveLayer(revealRect, Paint()); + highlightedPainter.paint(canvas, Offset.zero); + final mask = Paint() + ..blendMode = BlendMode.dstIn + ..shader = + LinearGradient( + colors: const [Colors.white, Colors.transparent], + ).createShader( + Rect.fromLTRB(boundary, box.top, boundary + feather, box.bottom), + ); + canvas.drawRect(revealRect, mask); + canvas.restore(); + canvas.restore(); + } + } + + void _paintLiftedText( + Canvas canvas, + TextPainter painter, + Path clip, + double lift, + ) { + canvas.save(); + canvas.translate(0, -lift); + canvas.clipPath(clip, doAntiAlias: false); + painter.paint(canvas, Offset.zero); + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _TimedLyricSweepPainter oldDelegate) { + return oldDelegate.segmentBoxes != segmentBoxes || + oldDelegate.starts != starts || + oldDelegate.ends != ends || + oldDelegate.currentPosition != currentPosition || + oldDelegate.highlightLift != highlightLift || + oldDelegate.pendingPainter != pendingPainter || + oldDelegate.highlightedPainter != highlightedPainter; + } +} diff --git a/test/player_metadata_controller_test.dart b/test/player_metadata_controller_test.dart new file mode 100644 index 00000000..8a3ed10d --- /dev/null +++ b/test/player_metadata_controller_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; + +MediaItem _item( + String id, { + String? source, + String? resolved, + Map metadata = const {}, +}) => MediaItem( + id: id, + title: id, + artist: 'Artist', + album: 'Album', + extras: { + 'source': source ?? '/music/$id.flac', + 'resolvedSource': ?resolved, + ...metadata, + }, +); + +void main() { + test('late result cannot overwrite a track switched away and back', () async { + final reads = >>[]; + final controller = PlayerMetadataController( + readMetadata: (_) { + final read = Completer>(); + reads.add(read); + return read.future; + }, + ); + addTearDown(controller.dispose); + final first = controller.load(_item('a')); + final second = controller.load(_item('b')); + final current = controller.load(_item('a')); + + reads[2].complete({'lyrics': '[00:01.00]Current A'}); + await current; + reads[0].complete({'lyrics': '[00:01.00]Old A'}); + reads[1].completeError(StateError('Old B failed')); + await Future.wait([first, second]); + + expect(controller.lyrics.lines.single.text, 'Current A'); + expect(controller.loading, isFalse); + }); + + test('resolved source supersedes an outstanding content URI read', () async { + final old = Completer>(); + final paths = []; + final controller = PlayerMetadataController( + readMetadata: (path) { + paths.add(path); + return path.startsWith('content://') + ? old.future + : Future.value({'lyrics': '[00:01.00]Resolved'}); + }, + ); + addTearDown(controller.dispose); + final source = 'content://library/song.flac'; + final pending = controller.load( + _item('a', source: source), + inspectUnresolvedContentUri: true, + ); + await controller.load( + _item('a', source: source, resolved: '/tmp/song.flac'), + ); + old.complete({'lyrics': '[00:01.00]Unresolved'}); + await pending; + + expect(paths, [source, '/tmp/song.flac']); + expect(controller.lyrics.lines.single.text, 'Resolved'); + }); + + test( + 'disposal prevents outstanding reads from notifying or mutating', + () async { + final read = Completer>(); + final controller = PlayerMetadataController( + readMetadata: (_) => read.future, + ); + var notifications = 0; + controller.addListener(() => notifications++); + final pending = controller.load(_item('a')); + final beforeDispose = notifications; + controller.dispose(); + read.complete({'lyrics': '[00:01.00]Late'}); + await pending; + await controller.load(_item('b')); + + expect(notifications, beforeDispose); + expect(controller.lyrics.isEmpty, isTrue); + }, + ); + + test( + 'unresolved SAF metadata stays on demand and reads are deduplicated', + () async { + final read = Completer>(); + final paths = []; + final controller = PlayerMetadataController( + readMetadata: (path) { + paths.add(path); + return read.future; + }, + ); + addTearDown(controller.dispose); + final item = _item('a', source: 'content://library/a.flac'); + await controller.load(item); + expect(paths, isEmpty); + expect(controller.loading, isFalse); + final pending = controller.load(item, inspectUnresolvedContentUri: true); + await controller.load(item, inspectUnresolvedContentUri: true); + expect(paths, [item.extras!['source']]); + read.complete({'lyrics': '[00:01.00]Embedded'}); + await pending; + await controller.load(item, inspectUnresolvedContentUri: true); + expect(paths, hasLength(1)); + }, + ); + + test('HTTP quality fallback avoids file probes and old lyrics', () async { + final paths = []; + final controller = PlayerMetadataController( + readMetadata: (path) async { + paths.add(path); + return {'lyrics': '[00:01.00]Local'}; + }, + ); + addTearDown(controller.dispose); + await controller.load(_item('local')); + final remote = _item( + 'remote', + source: 'https://audio.test/song.mp3', + resolved: 'https://cdn.audio.test/song.mp3', + metadata: {'format': 'mp3', 'bitrate': 320, 'sample_rate': 44100}, + ); + expect((await controller.readDetails(remote))['title'], 'remote'); + await controller.load(remote); + expect((await controller.readDetails(remote))['format'], 'mp3'); + + expect(paths, ['/music/local.flac']); + expect(controller.lyrics.isEmpty, isTrue); + expect(controller.qualityLabel, 'MP3 · 44.1 kHz · 320 kbps'); + }); + + test( + 'network metadata uses logical source despite playback HTTP URL', + () async { + final paths = []; + final controller = PlayerMetadataController( + readMetadata: (path) async { + paths.add(path); + return {'lyrics': '[00:01.00]Network'}; + }, + ); + addTearDown(controller.dispose); + await controller.load( + _item( + 'network', + source: 'network://library/song.flac', + resolved: 'http://127.0.0.1/playback', + ), + ); + expect(paths, ['network://library/song.flac']); + expect(controller.lyrics.lines.single.text, 'Network'); + }, + ); + + test('failed probes retain quality metadata and may retry', () async { + var reads = 0; + final controller = PlayerMetadataController( + readMetadata: (_) async { + if (++reads == 1) throw StateError('Temporarily unavailable'); + return { + 'lyrics': '[00:01.00]Recovered', + 'format': '', + 'sample_rate': 0, + 'lyricist': 'Writer', + 'lyrics_provider': 'Provider', + }; + }, + ); + addTearDown(controller.dispose); + final item = _item( + 'a', + metadata: {'format': 'FLAC', 'sample_rate': 48000, 'bit_depth': 24}, + ); + await controller.load(item); + expect(controller.loading, isFalse); + expect(controller.qualityLabel, 'FLAC · 24-bit · 48 kHz'); + await controller.load(item); + expect(controller.qualityLabel, 'FLAC · 24-bit · 48 kHz'); + expect(controller.lyrics.lines.single.text, 'Recovered'); + expect(controller.credits, (writers: 'Writer', provider: 'Provider')); + expect( + () => controller.metadata!['format'] = 'MP3', + throwsUnsupportedError, + ); + }); + + test('details snapshot probes SAF without changing lyrics state', () async { + final controller = PlayerMetadataController( + readMetadata: (_) async => { + 'title': 'Tagged', + 'lyrics': '[00:01.00]Lyrics', + }, + ); + addTearDown(controller.dispose); + final item = _item('a', source: 'content://library/a.flac'); + await controller.load(item); + final details = await controller.readDetails(item); + expect(details['title'], 'Tagged'); + expect(details['artist'], 'Artist'); + expect(controller.lyrics.isEmpty, isTrue); + expect(controller.loading, isFalse); + await controller.load(null); + expect(controller.source, isNull); + expect(controller.metadata, isNull); + }); + + test( + 'failed details retain their snapshot after the track changes', + () async { + final read = Completer>(); + final controller = PlayerMetadataController( + readMetadata: (path) => path.startsWith('content://') + ? read.future + : Future.value({'lyrics': '[00:01.00]Current'}), + ); + addTearDown(controller.dispose); + final item = _item( + 'a', + source: 'content://library/a.flac', + metadata: {'format': 'FLAC'}, + ); + await controller.load(item); + final pending = controller.readDetails(item); + await controller.load(_item('b')); + read.completeError(StateError('File unavailable')); + + expect(await pending, { + 'title': 'a', + 'artist': 'Artist', + 'album': 'Album', + 'format': 'FLAC', + }); + expect(controller.lyrics.lines.single.text, 'Current'); + }, + ); +}