import 'dart:async'; 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; import 'package:flutter/rendering.dart' show 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/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/aligned_lyric_pronunciation.dart'; import 'package:spotiflac_android/widgets/lyric_supplement_transition.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/player_artwork.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'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; import 'package:spotiflac_android/widgets/mornye_player_background.dart'; import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart'; import 'package:spotiflac_android/widgets/mornye_playback_button.dart'; import 'package:spotiflac_android/widgets/mornye_playback_time.dart'; import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; import 'package:spotiflac_android/widgets/mornye_player_actions_sheet.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; import 'package:spotiflac_android/widgets/mornye_player_artwork.dart'; import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; import 'package:spotiflac_android/widgets/mornye_player_favorite_button.dart'; final _log = AppLogger('NowPlaying'); /// Hero tag shared by the mini player artwork and the full player artwork so /// 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 _mornyeHeaderButtonSize = 48.0; const _mornyeCompactIconSize = 28.0; /// Slide-up route for the full player. Supports live drag-to-dismiss: the /// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and /// settles open or pops based on release position and velocity. class NowPlayingRoute extends PageRoute { NowPlayingRoute({this.miniPlayerGeometry}) : super( fullscreenDialog: true, settings: const RouteSettings(name: fullPlayerRouteName), ); /// Read at dismissal so rotation and collapsing navigation cannot leave a /// stale destination from when the player was opened. final ({Rect surface, Rect artwork})? Function()? miniPlayerGeometry; ({Rect bounds, Widget child, bool fullBleed})? Function()? _readArtwork; ({Rect surface, Rect artwork})? _dismissTarget; ({Rect bounds, Widget child, bool fullBleed})? _dismissArtwork; Rect? _dismissStart; double _dismissStartValue = 1; bool _interactiveTransition = false; int _dragGeneration = 0; // Keep the previous page painted where a drag exposes it. The player itself // fills the screen, including the status bar and bottom safe area. @override bool get opaque => false; @override Color? get barrierColor => null; @override String? get barrierLabel => null; @override bool get maintainState => true; @override Duration get transitionDuration => const Duration(milliseconds: 400); @override Duration get reverseTransitionDuration => const Duration(milliseconds: 300); void startDrag() { _dragGeneration++; controller?.stop(); if (!_interactiveTransition && controller != null) { controller!.value = Easing.emphasizedDecelerate.transform( controller!.value, ); } _interactiveTransition = true; changedInternalState(); } void updateDrag(DragUpdateDetails details, double pageHeight) { controller?.value -= (details.primaryDelta ?? 0) / pageHeight; } void endDrag(DragEndDetails details, double pageHeight) { final velocity = (details.primaryVelocity ?? 0) / pageHeight; final value = controller?.value ?? 1.0; if (velocity > 1.0 || (velocity >= 0 && value < 0.7)) { navigator?.pop(); } else { _settleOpen(); } } void cancelDrag() { _settleOpen(); } void _settleOpen() { final generation = _dragGeneration; // Keep the same linear position mapping until settling finishes. Switching // to the entrance curve on release jumps away from the finger's position. controller ?.animateTo( 1, duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, ) .whenCompleteOrCancel(() { if (generation != _dragGeneration || !isCurrent) return; _interactiveTransition = false; changedInternalState(); }); } @override bool didPop(void result) { final context = subtreeContext; if (context != null && context.isMornye && !MediaQuery.disableAnimationsOf(context) && (controller?.value ?? 0) > 0) { final target = miniPlayerGeometry?.call(); if (target != null && !target.surface.isEmpty) { final size = MediaQuery.sizeOf(context); _dismissStartValue = controller!.value; final position = _interactiveTransition ? _dismissStartValue : Easing.emphasizedDecelerate.transform(_dismissStartValue); _dismissStart = Rect.fromLTWH( 0, size.height * (1 - position), size.width, size.height, ); _dismissTarget = target; _dismissArtwork = _readArtwork?.call(); } } return super.didPop(result); } @override Widget buildPage( BuildContext context, Animation animation, Animation secondaryAnimation, ) { return _RouteDragRegion(route: this, child: const NowPlayingScreen()); } @override Widget buildTransitions( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { final target = _dismissTarget; // Restoring portrait can resize the window during dismissal. Geometry // captured in landscape no longer points at the mini player in that case. if (target != null && _dismissStart?.size == MediaQuery.sizeOf(context)) { final progress = Curves.easeInOutCubic.transform( (1 - animation.value / _dismissStartValue).clamp(0.0, 1.0), ); final bounds = Rect.lerp(_dismissStart, target.surface, progress)!; final size = MediaQuery.sizeOf(context); final cover = _dismissArtwork; return Stack( fit: StackFit.expand, children: [ Positioned.fromRect( rect: bounds, child: ClipRRect( key: const ValueKey('player-minimize-surface'), borderRadius: BorderRadius.circular(28 * progress), child: OverflowBox( alignment: Alignment.topLeft, minWidth: size.width, maxWidth: size.width, minHeight: size.height, maxHeight: size.height, child: Transform.scale( scale: bounds.width / size.width, alignment: Alignment.topLeft, child: Opacity( opacity: 1 - const Interval(0.15, 0.85).transform(progress), child: child, ), ), ), ), ), if (cover != null) Positioned.fromRect( rect: Rect.lerp(cover.bounds, target.artwork, progress)!, child: ClipRRect( key: const ValueKey('player-minimize-artwork'), borderRadius: BorderRadius.circular( cover.fullBleed ? 6 * progress : 12 - 6 * progress, ), child: cover.fullBleed ? ShaderMask( blendMode: BlendMode.dstIn, shaderCallback: (bounds) => LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0, 0.58, 1], colors: [ Colors.white, Colors.white, Colors.white.withValues(alpha: progress), ], ).createShader(bounds), child: cover.child, ) : cover.child, ), ), ], ); } final reversing = animation.status == AnimationStatus.reverse; // Mornye's full-bleed artwork travels with its panel. Material keeps the // independent cover flight back to the mini-player on button/back pop. final inPlacePop = reversing && !_interactiveTransition && !context.isMornye; final slide = _interactiveTransition ? animation : animation.drive(CurveTween(curve: Easing.emphasizedDecelerate)); final position = inPlacePop ? const AlwaysStoppedAnimation(Offset.zero) : slide.drive(Tween(begin: const Offset(0, 1), end: Offset.zero)); final opacity = inPlacePop ? animation : kAlwaysCompleteAnimation; return SlideTransition( position: position, child: FadeTransition(opacity: opacity, child: child), ); } } /// Routes vertical drags to [NowPlayingRoute]. Scrollables inside [child] /// still win the gesture arena, so this only fires on non-scrolling regions /// (app bar, artwork, tab bar). class _RouteDragRegion extends StatelessWidget { final NowPlayingRoute route; final Widget child; const _RouteDragRegion({required this.route, required this.child}); @override Widget build(BuildContext context) { final pageHeight = MediaQuery.sizeOf(context).height; return GestureDetector( onVerticalDragStart: (_) => route.startDrag(), onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight), onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), onVerticalDragCancel: route.cancelDrag, child: child, ); } } /// AppBar wrapper that fades with the route transition (see contentOpacity /// in [_NowPlayingScreenState.build]). class _FadingAppBar extends StatelessWidget implements PreferredSizeWidget { final Animation opacity; final AppBar child; const _FadingAppBar({required this.opacity, required this.child}); @override Size get preferredSize => child.preferredSize; @override Widget build(BuildContext context) => FadeTransition(opacity: opacity, child: child); } class NowPlayingScreen extends ConsumerStatefulWidget { const NowPlayingScreen({super.key}); @override ConsumerState createState() => _NowPlayingScreenState(); } class _NowPlayingScreenState extends ConsumerState { final PageController _pageController = PageController(); ProviderSubscription>? _mediaItemSub; ProviderSubscription? _lyricsPlayingSub; String? _loadedSource; String? _loadedResolvedSource; String? _loadedMetadataPath; Map? _metadata; ParsedLyrics _lyrics = ParsedLyrics.empty; bool _loadingMeta = false; int _currentPage = 0; bool _landscape = false; bool _bottomDragForwarding = false; double _bottomDragTotal = 0; bool _queueSheetShowing = false; String? _measuredMotionSource; double? _motionAspectRatio; String? _failedMotionSource; bool _lyricsControlsHidden = false; bool _lyricsOptionsOpen = false; bool _audioOutputOpen = false; double _lyricsScrollDistance = 0; Timer? _lyricsIdleTimer; final _lyricsPointers = {}; bool _lyricsPointerMoved = false; final _artworkHeaderKey = GlobalKey(); final _artworkControlsKey = GlobalKey(); final _artworkVolumeKey = GlobalKey(); final _expandedArtworkKey = GlobalKey(); final _compactArtworkKey = GlobalKey(); final _motionArtworkKey = GlobalKey(); Map _artworkForeground = {}; final _artworkColorsChanged = ValueNotifier(0); final _seekPreview = PlaybackSeekPreview(); @override void initState() { super.initState(); _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, // onPageChanged will not run again. Inspect an unresolved SAF URI now // instead of leaving the new track with an empty Lyrics page. inspectUnresolvedContentUri: _currentPage == 1, ); }, ); _lyricsPlayingSub = ref.listenManual(playbackPlayingProvider, ( previous, playing, ) { if (!playing && _lyricsControlsHidden) { setState(() => _lyricsControlsHidden = false); } _scheduleLyricsControlsHide(); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _loadMetadataForItem(ref.read(currentMediaItemProvider).value); }); } @override void didChangeDependencies() { super.didChangeDependencies(); final size = MediaQuery.sizeOf(context); _landscape = context.isMornye && size.width > size.height; if (_landscape || MediaQuery.accessibleNavigationOf(context)) { _lyricsControlsHidden = false; } _scheduleLyricsControlsHide(); } @override void dispose() { _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); _lyricsPlayingSub?.close(); _pageController.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(); final path = (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; return parseExplicitFlag(loadedMetadata?['explicit']) ?? parseExplicitFlag(mediaItem.extras?['explicit']) ?? false; } @override Widget build(BuildContext context) { final mornye = context.isMornye; final colorScheme = mornye ? MornyeTheme.fromContext( context, brightness: Brightness.dark, ).colorScheme : Theme.of(context).colorScheme; final mediaItem = ref.watch(currentMediaItemProvider).value; final controller = ref.read(musicPlayerControllerProvider); if (mediaItem == null) { return Scaffold( appBar: AppBar( leading: IconButton( tooltip: context.l10n.nowPlayingMinimize, icon: const Icon(Icons.keyboard_arrow_down), onPressed: () => Navigator.of(context).maybePop(), ), ), body: Center(child: Text(context.l10n.nowPlayingNothingPlaying)), ); } final source = mediaItem.extras?['source']?.toString() ?? ''; final motionState = mornye && !MediaQuery.disableAnimationsOf(context) ? ref.watch( playerMotionArtworkProvider(( album: mediaItem.album ?? '', artist: mediaItem.artist ?? '', )), ) : null; final resolvedMotion = motionState?.value; final motionArtwork = resolvedMotion?.source == _failedMotionSource ? null : resolvedMotion; final motionRatio = motionArtwork?.aspectRatio ?? (_measuredMotionSource == motionArtwork?.source ? _motionAspectRatio : null); final squareArtwork = motionArtwork == null || (motionRatio != null && motionRatio >= 0.95); if (motionArtwork == null) _artworkForeground = {}; Widget artwork() => MornyePlayerArtwork( mediaItem: mediaItem, videoUrl: motionArtwork?.source, resolvingVideo: motionState?.isLoading == true, onAspectRatioChanged: (ratio) { if (!mounted || (_measuredMotionSource == motionArtwork?.source && _motionAspectRatio == ratio)) { return; } setState(() { _measuredMotionSource = motionArtwork?.source; _motionAspectRatio = ratio; }); }, onError: () { if (mounted) { setState(() => _failedMotionSource = motionArtwork?.source); } }, ); // The Mornye route moves the entire player together. A second, // delayed content fade would make dismissal appear to pause partway down. final route = ModalRoute.of(context); final routeAnimation = route?.animation ?? kAlwaysCompleteAnimation; if (mornye && route is NowPlayingRoute) { route._readArtwork = () { final key = _landscape ? _expandedArtworkKey : _currentPage != 0 ? _compactArtworkKey : motionArtwork != null ? _motionArtworkKey : _expandedArtworkKey; final box = key.currentContext?.findRenderObject(); if (box is! RenderBox || !box.attached || !box.hasSize) return null; final overlay = route.navigator?.overlay?.context.findRenderObject(); return ( bounds: MatrixUtils.transformRect( box.getTransformTo(overlay), Offset.zero & box.size, ), fullBleed: key == _motionArtworkKey, child: Theme( data: MornyeTheme.fromContext(context, brightness: Brightness.dark), child: key == _compactArtworkKey ? PlayerArtwork( artUri: mediaItem.artUri?.toString(), colorScheme: colorScheme, ) : artwork(), ), ); }; } final contentOpacity = mornye ? kAlwaysCompleteAnimation : routeAnimation.drive( CurveTween(curve: const Interval(0.4, 1.0, curve: Curves.easeOut)), ); final player = Scaffold( backgroundColor: mornye ? Colors.transparent : colorScheme.surface, appBar: _FadingAppBar( opacity: contentOpacity, child: AppBar( backgroundColor: mornye ? Colors.transparent : colorScheme.surface, foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, systemOverlayStyle: mornye ? SystemUiOverlayStyle.light : null, toolbarHeight: mornye ? (MediaQuery.orientationOf(context) == Orientation.landscape ? 0 : 36) : kToolbarHeight, automaticallyImplyLeading: false, title: mornye ? MediaQuery.orientationOf(context) == Orientation.landscape ? null : IconButton( tooltip: context.l10n.nowPlayingMinimize, onPressed: () => Navigator.of(context).maybePop(), icon: Container( width: 36, height: 5, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(3), ), ), ) : Text(context.l10n.nowPlayingTitle), centerTitle: true, leading: mornye ? null : IconButton( tooltip: context.l10n.nowPlayingMinimize, icon: const Icon(Icons.keyboard_arrow_down), onPressed: () => Navigator.of(context).maybePop(), ), actions: mornye ? null : [ const AudioOutputButton(), IconButton( tooltip: context.l10n.nowPlayingUpNext, icon: const Icon(Icons.queue_music), onPressed: () => _showQueueSheet(colorScheme), ), IconButton( tooltip: MaterialLocalizations.of( context, ).moreButtonTooltip, icon: const Icon(Icons.more_vert), onPressed: () => _showMoreActions( context: context, mediaItem: mediaItem, source: source, colorScheme: colorScheme, ), ), ], ), ), body: FadeTransition( opacity: contentOpacity, child: SafeArea( child: Column( children: [ Expanded( child: mornye ? _mornyePlayerPage( mediaItem, controller, colorScheme, motionArtwork: artwork(), artworkAspectRatio: motionRatio, insetArtwork: motionArtwork == null, ) : PageView( controller: _pageController, onPageChanged: (page) { if (_currentPage != page) { setState(() => _currentPage = page); } if (page == 1) { _loadMetadataForItem( ref.read(currentMediaItemProvider).value, inspectUnresolvedContentUri: true, ); } }, children: [ _playerPage(mediaItem, controller, colorScheme), _lyricsSection( colorScheme, isActive: _currentPage == 1, ), ], ), ), _autoHidingLyricsControls( _queueSwipeRegion( colorScheme, Column( mainAxisSize: MainAxisSize.min, children: [ if (mornye && !_landscape) FractionallySizedBox( widthFactor: 0.72, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( tooltip: _currentPage == 1 ? context.l10n.nowPlayingTabPlayer : context.l10n.nowPlayingTabLyrics, isSelected: _currentPage == 1, color: Colors.white, style: IconButton.styleFrom( backgroundColor: _currentPage == 1 ? Colors.white.withValues(alpha: 0.16) : Colors.transparent, ), icon: const Icon(CupertinoIcons.quote_bubble), onPressed: _toggleMornyeLyrics, ), AudioOutputButton( color: Colors.white, onPickerChanged: (open) { if (!mounted) return; setState(() => _audioOutputOpen = open); _scheduleLyricsControlsHide(); }, ), IconButton( tooltip: context.l10n.nowPlayingUpNext, color: Colors.white, isSelected: _currentPage == 2, style: IconButton.styleFrom( backgroundColor: _currentPage == 2 ? Colors.white.withValues(alpha: 0.16) : Colors.transparent, ), icon: const Icon(CupertinoIcons.list_bullet), onPressed: () => _setMornyePage(_currentPage == 2 ? 0 : 2), ), ], ), ) else if (!mornye) _PageTabBar( controller: _pageController, colorScheme: colorScheme, labels: [ context.l10n.nowPlayingTabPlayer, context.l10n.nowPlayingTabLyrics, ], ), if (!_landscape) const SizedBox(height: 8), ], ), ), ), ], ), ), ), ); if (!mornye) return player; return HeroMode( // A route Hero renders outside the panel's gradient and slide, briefly // exposing a detached rectangle. Keep Mornye artwork inside its panel. enabled: false, child: Theme( data: MornyeTheme.fromContext(context, brightness: Brightness.dark), child: Stack( fit: StackFit.expand, children: [ MornyeArtworkContrast( enabled: motionArtwork != null && !_landscape && _currentPage == 0, targets: { 'header': _artworkHeaderKey, 'controls': _artworkControlsKey, 'volume': _artworkVolumeKey, }, onChanged: (colors) { if (!mounted) return; _artworkForeground = colors; _artworkColorsChanged.value++; }, child: MornyePlayerBackground( artUri: mediaItem.artUri, squareArtwork: squareArtwork, artworkAspectRatio: motionRatio, artwork: motionArtwork != null && !_landscape && _currentPage == 0 ? _transitionArtwork( _motionArtworkKey, Hero(tag: kNowPlayingArtworkHeroTag, child: artwork()), ) : null, ), ), Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { if (!_canAutoHideLyricsControls) return; if (_lyricsPointers.isEmpty) _lyricsPointerMoved = false; _lyricsPointers[event.pointer] = event.position; _lyricsIdleTimer?.cancel(); }, onPointerMove: (event) { final origin = _lyricsPointers[event.pointer]; if (origin != null && (event.position - origin).distance > 12) { _lyricsPointerMoved = true; } }, onPointerUp: (event) => _endLyricsInteraction(event.pointer), onPointerCancel: (event) => _endLyricsInteraction(event.pointer, cancelled: true), child: player, ), ], ), ), ); } Widget _transitionArtwork(GlobalKey key, Widget child) { final route = ModalRoute.of(context); return AnimatedBuilder( animation: route?.animation ?? kAlwaysCompleteAnimation, child: child, builder: (_, child) => Opacity( key: key, opacity: route is NowPlayingRoute && route._dismissArtwork != null ? 0 : 1, child: child, ), ); } /// Swipe up (player content or bottom tab strip) opens the queue sheet; a /// downward drag is forwarded to the route's drag-to-dismiss instead. Widget _queueSwipeRegion(ColorScheme colorScheme, Widget child) { final route = ModalRoute.of(context); final npRoute = route is NowPlayingRoute ? route : null; final pageHeight = MediaQuery.sizeOf(context).height; return GestureDetector( behavior: HitTestBehavior.translucent, onVerticalDragStart: (_) { _bottomDragForwarding = false; _bottomDragTotal = 0; }, onVerticalDragUpdate: (details) { if (_bottomDragForwarding) { npRoute?.updateDrag(details, pageHeight); return; } _bottomDragTotal += details.primaryDelta ?? 0; if (_bottomDragTotal > 12 && npRoute != null) { npRoute.startDrag(); _bottomDragForwarding = true; } }, onVerticalDragEnd: (details) { if (_bottomDragForwarding) { npRoute?.endDrag(details, pageHeight); } else if ((details.primaryVelocity ?? 0) < -300 || _bottomDragTotal < -40) { _showQueueSheet(colorScheme); } }, onVerticalDragCancel: () { if (_bottomDragForwarding) npRoute?.cancelDrag(); }, child: child, ); } /// The artwork sits inside a scroll view, which wins vertical drags from /// the route-level drag region — so the artwork hooks the route directly. Widget _artworkDragRegion(BuildContext context, Widget child) { final route = ModalRoute.of(context); if (route is! NowPlayingRoute) return PlayerTrackSwipeRegion(child: child); final pageHeight = MediaQuery.sizeOf(context).height; return PlayerTrackSwipeRegion( child: GestureDetector( onVerticalDragStart: (_) => route.startDrag(), onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight), onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), onVerticalDragCancel: route.cancelDrag, child: child, ), ); } Widget _playerPage( MediaItem mediaItem, MusicPlayerController controller, ColorScheme colorScheme, ) { return LayoutBuilder( builder: (context, constraints) { Widget artworkAt(double artSize) => Center( child: _artworkDragRegion( context, Hero( tag: kNowPlayingArtworkHeroTag, child: ClipRRect( borderRadius: BorderRadius.circular(20), child: SizedBox( width: artSize, height: artSize, child: AnimatedSwitcher( duration: const Duration(milliseconds: 450), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween(begin: 0.94, end: 1.0).animate(animation), child: child, ), ), child: PlayerArtwork( key: ValueKey( mediaItem.artUri?.toString() ?? mediaItem.id, ), artUri: mediaItem.artUri?.toString(), colorScheme: colorScheme, cacheWidth: (artSize * MediaQuery.devicePixelRatioOf(context)) .round(), ), ), ), ), ), ), ); // Tablet/landscape: artwork pane left, metadata and controls right, // instead of one narrow column in a sea of empty space. final twoPane = constraints.maxWidth >= 720 && constraints.maxWidth > constraints.maxHeight; if (twoPane) { final artSize = (constraints.maxHeight - 96).clamp(0.0, 420.0); return Row( children: [ Expanded(child: artworkAt(artSize)), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - 32, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: _metadataAndControls( mediaItem, controller, colorScheme, ), ), ), ), ), ], ); } final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0); // Not user-scrollable: a swipe up here opens the queue instead, // and a swipe down still dismisses the player via the route. return _queueSwipeRegion( colorScheme, SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(vertical: 16), child: ConstrainedBox( constraints: BoxConstraints( minHeight: constraints.maxHeight - 32, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ artworkAt(artSize), const SizedBox(height: 32), ..._metadataAndControls(mediaItem, controller, colorScheme), ], ), ), ), ); }, ); } void _toggleMornyeLyrics() { _setMornyePage(_currentPage == 1 ? 0 : 1); } void _setMornyePage(int page) { setState(() { _currentPage = page; _lyricsControlsHidden = false; _lyricsScrollDistance = 0; }); _scheduleLyricsControlsHide(); if (_currentPage == 1) { _loadMetadataForItem( ref.read(currentMediaItemProvider).value, inspectUnresolvedContentUri: true, ); } } bool get _canAutoHideLyricsControls => context.isMornye && !_landscape && _currentPage == 1 && ref.read(playbackPlayingProvider) && !MediaQuery.accessibleNavigationOf(context); void _scheduleLyricsControlsHide() { _lyricsIdleTimer?.cancel(); if (!_canAutoHideLyricsControls || _lyricsOptionsOpen || _audioOutputOpen || _lyricsControlsHidden || _lyricsPointers.isNotEmpty) { return; } _lyricsIdleTimer = Timer(const Duration(seconds: 5), () { if (!mounted || !_canAutoHideLyricsControls) return; if (ModalRoute.of(context)?.isCurrent == false) { _scheduleLyricsControlsHide(); return; } setState(() => _lyricsControlsHidden = true); }); } void _endLyricsInteraction(int pointer, {bool cancelled = false}) { if (_lyricsPointers.remove(pointer) == null || _lyricsPointers.isNotEmpty) { return; } if (!cancelled && !_lyricsPointerMoved && _lyricsControlsHidden) { setState(() => _lyricsControlsHidden = false); } _scheduleLyricsControlsHide(); } Widget _autoHidingLyricsControls(Widget child) { if (!context.isMornye) return child; final hidden = _currentPage == 1 && _lyricsControlsHidden && !_landscape; final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); final content = ClipRect( child: Align( heightFactor: hidden ? 0 : 1, child: IgnorePointer( ignoring: hidden, child: ExcludeSemantics( excluding: hidden, child: AnimatedOpacity( opacity: hidden ? 0 : 1, duration: motion, child: child, ), ), ), ), ); return motion == Duration.zero ? content : AnimatedSize( duration: motion, curve: Curves.easeInOutCubic, alignment: Alignment.topCenter, child: content, ); } Widget _mornyePlayerPage( MediaItem mediaItem, MusicPlayerController controller, ColorScheme colorScheme, { required Widget motionArtwork, double? artworkAspectRatio, required bool insetArtwork, }) { final showLyrics = _currentPage == 1; final showQueue = _currentPage == 2; final compactStage = showLyrics || showQueue; final artworkTopInset = MediaQuery.paddingOf(context).top + 36; ColorScheme foreground(String region) { final color = compactStage ? null : _artworkForeground[region] ?? colorScheme.onSurface; return color == null ? colorScheme : colorScheme.copyWith( onSurface: color, onSurfaceVariant: color.withValues(alpha: 0.72), ); } final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); final queue = ref.watch(playQueueProvider).value ?? const []; final reportedIndex = ref.watch( playbackStateProvider.select((state) => state.value?.queueIndex), ); final currentIndex = reportedIndex != null && reportedIndex >= 0 && reportedIndex < queue.length && queue[reportedIndex].id == mediaItem.id ? reportedIndex : queue.indexWhere((item) => item.id == mediaItem.id); final page = NotificationListener( onNotification: (notification) { if (!showLyrics || _landscape || notification.dragDetails == null || notification.metrics.axis != Axis.vertical) { return false; } final delta = notification.scrollDelta ?? 0; if (delta.sign != _lyricsScrollDistance.sign) { _lyricsScrollDistance = 0; } _lyricsScrollDistance += delta; if (_lyricsScrollDistance.abs() >= 16) { final hidden = _lyricsScrollDistance > 0; if (hidden != _lyricsControlsHidden) { setState(() => _lyricsControlsHidden = hidden); } _lyricsScrollDistance = 0; } return false; }, child: LayoutBuilder( builder: (context, constraints) { final screenSize = MediaQuery.sizeOf(context); final landscape = screenSize.width > screenSize.height; final scale = MediaQuery.textScalerOf(context).scale(17) / 17; const compactCoverSize = 72.0; final compactHeaderHeight = (44 * scale + 4).clamp( compactCoverSize, double.infinity, ); Widget stage({ bool artworkOnly = false, double progress = 0, }) => LayoutBuilder( builder: (context, stage) { final compact = compactStage && !artworkOnly; final expandedArtwork = artworkOnly || (!compactStage && insetArtwork); final artSize = (stage.maxWidth - (artworkOnly ? 56 : 48)).clamp( 0.0, (stage.maxHeight - (artworkOnly ? 24 : 8)).clamp(0.0, 360.0), ); final ratio = artworkAspectRatio ?? 1.0; final artWidth = ratio < 1 ? artSize * ratio : artSize; final artHeight = ratio > 1 ? artSize / ratio : artSize; final fullBleed = !insetArtwork && !artworkOnly; // Match the background's bounds even while this cover is hidden. // Opening/closing a panel then moves the cover between the same // two positions, with the playback controls painted above it. final motionHeight = artworkAspectRatio != null ? (screenSize.width / ratio).clamp( 0.0, screenSize.height * 0.75, ) : screenSize.height * 0.66; return Stack( clipBehavior: Clip.none, children: [ Positioned( top: 0, left: 0, right: 0, height: compact ? compactHeaderHeight + 8 : stage.maxHeight, child: const PlayerTrackSwipeRegion( child: SizedBox.expand(), ), ), Positioned.fill( top: 8 + compactHeaderHeight + 16, child: IgnorePointer( ignoring: !compact, child: ExcludeSemantics( excluding: !compact, child: AnimatedSwitcher( duration: motion, switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.035), end: Offset.zero, ).animate(animation), child: child, ), ), layoutBuilder: (currentChild, previousChildren) => Stack( fit: StackFit.expand, children: [ // Preserve each panel's parent and key when // it becomes outgoing, including lyric scroll. for (final child in [ ...previousChildren, ?currentChild, ]) IgnorePointer( key: child.key, ignoring: child != currentChild, child: ExcludeSemantics( excluding: child != currentChild, child: child, ), ), ], ), // Keep the outgoing panel until it fades out; // closing the queue must never substitute lyrics. child: compact ? KeyedSubtree( key: ValueKey(_currentPage), child: showQueue ? MornyePlayerQueue( colorScheme: colorScheme, onShuffleLibrary: () => _shuffleLibrary(controller), ) : _lyricsSection( colorScheme, isActive: showLyrics, ), ) : null, ), ), ), ), Consumer( builder: (context, ref, child) => TweenAnimationBuilder( tween: Tween( end: !insetArtwork || ref.watch(playbackPlayingProvider) ? 1 : 0.73, ), duration: motion, curve: Curves.easeInOutCubic, builder: (context, scale, child) => Positioned.fromRect( // Both cover layers travel to the actual resting size, // including the smaller cover used while paused. rect: Rect.lerp( Rect.fromLTWH( fullBleed ? 0 : (stage.maxWidth - artWidth * scale) / 2, fullBleed ? -artworkTopInset : (stage.maxHeight - artHeight * scale) / 2, fullBleed ? stage.maxWidth : artWidth * scale, fullBleed ? motionHeight : artHeight * scale, ), const Rect.fromLTWH( _mornyeContentInset, 8, compactCoverSize, compactCoverSize, ), progress, )!, child: child!, ), child: child, ), // Both layers share the header's progress, so reversing a // transition reuses the live cover instead of inserting a // duplicate while its previous instance is still fading. child: Stack( fit: StackFit.expand, children: [ for (final expanded in [true, false]) if (expanded ? !fullBleed && (!compact || progress < 1) : compact || progress > 0) HeroMode( key: ValueKey(expanded), enabled: expanded ? expandedArtwork : compact, child: IgnorePointer( ignoring: expanded ? !expandedArtwork : !compact, child: FadeTransition( opacity: AlwaysStoppedAnimation( expanded ? 1 - progress : progress, ), child: KeyedSubtree( key: ValueKey( expanded ? 'full-player-artwork' : 'compact-player-artwork', ), child: _artworkDragRegion( context, Hero( tag: kNowPlayingArtworkHeroTag, child: expanded ? DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow( color: Color(0x40000000), blurRadius: 28, offset: Offset(0, 16), ), ], ), child: _transitionArtwork( _expandedArtworkKey, ClipRRect( borderRadius: BorderRadius.circular( 12, ), child: motionArtwork, ), ), ) : Semantics( button: true, label: context .l10n .nowPlayingTabPlayer, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _setMornyePage(0), child: _transitionArtwork( _compactArtworkKey, ClipRRect( borderRadius: BorderRadius.circular( 12, ), child: PlayerArtwork( artUri: mediaItem.artUri ?.toString(), colorScheme: colorScheme, cacheWidth: (360 * MediaQuery.devicePixelRatioOf( context, )) .round(), ), ), ), ), ), ), ), ), ), ), ), ], ), ), ], ); }, ); // Portrait video must not push the metadata/transport below the // square-cover position. Leave room above the anchored volume row. final volumeGap = 8 + (constraints.maxHeight - 440).clamp(0.0, 24.0); // Keep transport close to the timeline; spare height belongs above // the volume row without moving either slider or the track header. final transportShift = landscape ? 0.0 : ((volumeGap - 16) / 2).clamp(0.0, 8.0); Widget controls() => AnimatedBuilder( animation: _artworkColorsChanged, builder: (context, _) => _PlaybackControls( key: _artworkControlsKey, mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, seekPreview: _seekPreview, colorScheme: foreground('controls'), qualityLabel: _qualityLabel(), compact: landscape, transportTopPadding: 16 + transportShift, ), ); if (landscape) { return MornyeLandscapePlayer( page: _currentPage, isPlaying: ref.watch(playbackPlayingProvider), controlsHeldOpen: _lyricsOptionsOpen, onPageChanged: _setMornyePage, artwork: stage(artworkOnly: true), header: _trackHeader(mediaItem, colorScheme, compact: true), lyrics: _lyricsSection(colorScheme, isActive: showLyrics), lyricsOptions: showLyrics ? _lyricsOptionsButton(colorScheme) : null, queue: MornyePlayerQueue( colorScheme: colorScheme, onShuffleLibrary: () => _shuffleLibrary(controller), ), controls: controls(), volume: const MornyeVolumeControl(), ); } Widget content() => Column( children: [ Expanded( child: TweenAnimationBuilder( tween: Tween(begin: 0, end: compactStage ? 1 : 0), duration: motion, curve: Curves.easeInOutCubic, builder: (context, progress, _) => CustomMultiChildLayout( delegate: _PlayerHeaderLayout( progress: progress, compactHeight: compactHeaderHeight, compactLeft: _mornyeContentInset + compactCoverSize + 12, ), children: [ LayoutId( id: _PlayerHeaderSlot.stage, child: stage(progress: progress), ), LayoutId( id: _PlayerHeaderSlot.header, child: KeyedSubtree( key: const ValueKey('player-track-header'), child: AnimatedBuilder( key: _artworkHeaderKey, animation: _artworkColorsChanged, builder: (context, _) => _trackHeader( mediaItem, foreground('header'), compactProgress: progress, ), ), ), ), ], ), ), ), _autoHidingLyricsControls( Column( mainAxisSize: MainAxisSize.min, children: [ controls(), SizedBox(height: volumeGap - transportShift), AnimatedBuilder( animation: _artworkColorsChanged, builder: (context, _) => MornyeVolumeControl( key: _artworkVolumeKey, foreground: foreground('volume').onSurface, ), ), const SizedBox(height: 8), ], ), ), ], ); // Controls keep their intrinsic height. Small portrait screens and // large accessibility text can scroll instead of clipping the volume. final minHeight = (showLyrics && _lyricsControlsHidden ? 250.0 : 480.0) + (scale - 1).clamp(0, 3) * 120; if (constraints.maxHeight < minHeight) { return SingleChildScrollView( child: SizedBox(height: minHeight, child: content()), ); } return content(); }, ), ); return PlayerTrackSwipe( queue: queue, currentIndex: currentIndex, onSelected: (item) async { final latest = ref.read(playQueueProvider).value ?? const []; final index = latest.indexWhere((entry) => identical(entry, item)); if (index >= 0) await controller.jumpTo(index); }, child: page, ); } Widget _trackHeader( MediaItem mediaItem, ColorScheme colorScheme, { bool compact = false, double? compactProgress, }) { final progress = compactProgress ?? (compact ? 1.0 : 0.0); final actionSize = compactProgress == null ? (_currentPage == 0 ? 24.0 : _mornyeCompactIconSize) : 24 + (_mornyeCompactIconSize - 24) * progress; return Builder( builder: (context) => Row( children: [ Expanded( child: PlayerTrackSwipeTitles( current: mediaItem, builder: (mediaItem) => Builder( builder: (titleContext) => InkWell( borderRadius: BorderRadius.circular(8), onTap: (mediaItem.artist ?? '').trim().isEmpty && (mediaItem.album ?? '').trim().isEmpty ? null : () => _showTrackNavigationMenu(titleContext, mediaItem), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ OverflowMarquee( resetKey: (mediaItem.id, mediaItem.title), child: ExplicitTrackTitle( title: mediaItem.title, explicit: _isExplicit(mediaItem), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 22 - 4 * progress, fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), ), ), const SizedBox(height: 4), OverflowMarquee( resetKey: (mediaItem.id, mediaItem.artist), child: Text( mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 20 - 4 * progress, color: colorScheme.onSurface.withValues( alpha: 0.72, ), ), ), ), ], ), ), ), ), ), MornyePlayerFavoriteButton( key: ValueKey(mediaItem.id), mediaItem: mediaItem, compact: _currentPage != 0, iconSize: actionSize, color: colorScheme.onSurface, ), Builder( builder: (buttonContext) => SizedBox.square( dimension: _mornyeHeaderButtonSize, child: IconButton( tooltip: MaterialLocalizations.of(context).moreButtonTooltip, color: colorScheme.onSurface, iconSize: actionSize, icon: const Icon(CupertinoIcons.ellipsis), onPressed: () => _showMoreActions( context: context, mediaItem: mediaItem, source: mediaItem.extras?['source']?.toString() ?? '', colorScheme: colorScheme, anchor: mornyeMenuAnchor(buttonContext), ), ), ), ), ], ), ); } /// Title/artist, seek slider, and transport buttons — shared by the /// portrait column and the landscape right pane. List _metadataAndControls( MediaItem mediaItem, MusicPlayerController controller, ColorScheme colorScheme, ) { return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: AnimatedSwitcher( duration: const Duration(milliseconds: 350), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.15), end: Offset.zero, ).animate(animation), child: child, ), ), child: Row( key: ValueKey(mediaItem.id), children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ ExplicitTrackTitle( title: mediaItem.title, explicit: _isExplicit(mediaItem), style: Theme.of(context).textTheme.headlineSmall ?.copyWith( fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 6), Consumer( builder: (context, ref, _) { final track = ref .watch(playerCollectionTrackProvider(mediaItem)) .value; return ClickableArtistName( artistName: mediaItem.artist ?? '', artistId: track?.artistId, extensionId: track?.source, style: Theme.of(context).textTheme.titleMedium ?.copyWith(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ); }, ), ], ), ), ], ), ), ), const SizedBox(height: 24), _PlaybackControls( key: ValueKey(mediaItem.id), mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, seekPreview: _seekPreview, colorScheme: colorScheme, qualityLabel: _qualityLabel(), ), ]; } Widget _lyricsSection(ColorScheme colorScheme, {required bool isActive}) { if (_loadingMeta) { return const Center(child: CircularProgressIndicator()); } if (_lyrics.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.lyrics_outlined, size: 40, color: colorScheme.onSurfaceVariant, ), const SizedBox(height: 12), Text( context.l10n.nowPlayingNoLyrics, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ); } if (_lyrics.synced) { final visibility = ref.watch( settingsProvider.select( (settings) => ( settings.playerShowPronunciation, settings.playerShowTranslation, ), ), ); final options = isActive && !_landscape ? _lyricsOptionsButton(colorScheme) : null; return Stack( fit: StackFit.expand, children: [ _SyncedLyricsView( lyrics: _lyrics, seekPreview: _seekPreview, credits: _lyricsCredits(), colorScheme: colorScheme, isActive: isActive, showPronunciation: visibility.$1, showTranslation: visibility.$2, ), if (options != null) Positioned( left: context.isMornye ? _mornyeContentInset : 24, bottom: 8, child: _autoHidingLyricsControls(options), ), ], ); } return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( _lyrics.plainText, style: Theme.of(context).textTheme.titleMedium?.copyWith( height: 1.6, color: colorScheme.onSurface, ), textAlign: TextAlign.center, ), ?_lyricsCredits(), ], ), ); } _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'); if (writers == null && provider == null) return null; return _LyricsCredits(writers: writers, provider: provider); } Widget? _lyricsOptionsButton(ColorScheme colorScheme) { if (_loadingMeta || !_lyrics.synced) return null; final pronunciation = _lyrics.lines.any( (line) => line.romanization?.trim().isNotEmpty == true, ); final translation = _lyrics.lines.any( (line) => line.translation?.trim().isNotEmpty == true, ); if (!pronunciation && !translation) return null; return Builder( builder: (buttonContext) => IconButton.filledTonal( key: const ValueKey('lyrics-language-options'), tooltip: context.l10n.nowPlayingLyricsLanguageOptions, icon: const Icon(Icons.translate_rounded), style: IconButton.styleFrom( foregroundColor: _lyricsOptionsOpen ? colorScheme.surface : colorScheme.onSurface, backgroundColor: colorScheme.onSurface.withValues( alpha: _lyricsOptionsOpen ? 0.85 : 0.16, ), ), onPressed: () => _showLyricsOptions( buttonContext, pronunciation: pronunciation, translation: translation, ), ), ); } Future _showLyricsOptions( BuildContext buttonContext, { required bool pronunciation, required bool translation, }) async { final settings = ref.read(settingsProvider); final l10n = context.l10n; final actions = <(String, String, IconData)>[ if (pronunciation) ( 'pronunciation', settings.playerShowPronunciation ? l10n.nowPlayingHidePronunciation : l10n.nowPlayingShowPronunciation, settings.playerShowPronunciation ? Icons.voice_over_off_outlined : Icons.record_voice_over_outlined, ), if (translation) ( 'translation', settings.playerShowTranslation ? l10n.nowPlayingHideTranslation : l10n.nowPlayingShowTranslation, Icons.translate_rounded, ), ]; _lyricsIdleTimer?.cancel(); setState(() => _lyricsOptionsOpen = true); try { final anchor = mornyeMenuAnchor(buttonContext); var menuWidth = 240.0; for (final (_, label, _) in actions) { final painter = TextPainter( text: TextSpan( text: label, style: Theme.of( context, ).textTheme.bodyLarge?.copyWith(fontSize: 15), ), textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout(); if (painter.width + 80 > menuWidth) menuWidth = painter.width + 80; painter.dispose(); } final action = context.isMornye ? await showMornyeContextMenu( context: buttonContext, anchor: anchor, preferAbove: true, maxWidth: menuWidth, builder: (menuContext) => MornyeContextMenu( dense: true, groups: [ [ for (final (value, label, icon) in actions) MornyeMenuAction( icon: icon, label: label, onPressed: () => Navigator.of(menuContext).pop(value), ), ], ], ), ) : await showMenu( context: buttonContext, position: RelativeRect.fromRect( anchor ?? Rect.zero, Offset.zero & MediaQuery.sizeOf(context), ), items: [ for (final (value, label, icon) in actions) PopupMenuItem( value: value, child: Row( children: [ Icon(icon), const SizedBox(width: 12), Flexible(child: Text(label)), ], ), ), ], ); if (!mounted) return; final notifier = ref.read(settingsProvider.notifier); if (action == 'pronunciation') { notifier.setPlayerShowPronunciation(!settings.playerShowPronunciation); } else if (action == 'translation') { notifier.setPlayerShowTranslation(!settings.playerShowTranslation); } } finally { if (mounted) { setState(() => _lyricsOptionsOpen = false); _scheduleLyricsControlsHide(); } } } Future _openExternally(String source) async { if (source.isEmpty) return; try { await openFile(source); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), ), ), ); } } Future _showTrackNavigationMenu( BuildContext titleContext, MediaItem mediaItem, ) async { final action = await showMornyeContextMenu( context: titleContext, preferAbove: true, builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem), ); if (!mounted) return; if (action == 'album') { await _goToCurrentAlbum( mediaItem: mediaItem, source: mediaItem.extras?['source']?.toString() ?? '', ); } else if (action == 'artist') { await _goToCurrentArtist(mediaItem); } } Future _goToCurrentArtist(MediaItem mediaItem) async { final track = ref.read(playerCollectionTrackProvider(mediaItem)).value; await navigateToArtist( context, artistName: mediaItem.artist ?? '', artistId: track?.artistId, extensionId: track?.source, ); } Future _updatePlayerCollection( MediaItem item, { required bool favorite, }) async { try { final provider = playerCollectionTrackProvider(item); if (ref.read(provider).hasError) ref.invalidate(provider); final track = await ref.read(provider.future); if (!mounted) return; if (favorite) { await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); } else { await showAddTrackToPlaylistSheet(context, ref, track); } } catch (error) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.snackbarError(context.friendlyError(error)), ), ), ); } } Future _showMoreActions({ required BuildContext context, required MediaItem mediaItem, required String source, required ColorScheme colorScheme, Rect? anchor, }) async { final controller = ref.read(musicPlayerControllerProvider); final sleepTimerEndsAt = controller.sleepTimerEndsAt; final sleepTimerSubtitle = sleepTimerEndsAt == null ? null : context.l10n.nowPlayingSleepTimerActive( MaterialLocalizations.of( context, ).formatTimeOfDay(TimeOfDay.fromDateTime(sleepTimerEndsAt)), ); final action = context.isMornye ? await showMornyeContextMenu( context: context, anchor: anchor, preferAbove: true, builder: (_) => MornyePlayerActionsSheet( mediaItem: mediaItem, sleepTimerSubtitle: sleepTimerSubtitle, ), ) : await showAppBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, title: mediaItem.title, subtitle: mediaItem.artist, builder: (sheetContext) => Padding( padding: const EdgeInsets.only(bottom: 16), child: SettingsGroup( children: [ if ((mediaItem.album ?? '').trim().isNotEmpty) SettingsItem( icon: Icons.album_outlined, title: sheetContext.l10n.homeGoToAlbum, onTap: () => Navigator.of(sheetContext).pop('album'), ), SettingsItem( icon: Icons.info_outline, title: sheetContext.l10n.nowPlayingDetails, onTap: () => Navigator.of(sheetContext).pop('details'), ), SettingsItem( icon: Icons.bedtime_outlined, title: sheetContext.l10n.nowPlayingSleepTimer, subtitle: sleepTimerSubtitle, onTap: () => Navigator.of(sheetContext).pop('sleepTimer'), ), SettingsItem( icon: Icons.open_in_new, title: sheetContext.l10n.nowPlayingOpenInExternalPlayer, trailing: Icon( Icons.open_in_new, size: 18, color: Theme.of( sheetContext, ).colorScheme.onSurfaceVariant, ), showDivider: false, onTap: () => Navigator.of(sheetContext).pop('external'), ), ], ), ), ); if (!mounted || !context.mounted) return; switch (action) { case 'favorite': await _updatePlayerCollection(mediaItem, favorite: true); break; case 'playlist': await _updatePlayerCollection(mediaItem, favorite: false); break; case 'artist': await _goToCurrentArtist(mediaItem); break; case 'share': await SharePlus.instance.share( ShareParams( text: '${mediaItem.title} — ${mediaItem.artist ?? ''}', sharePositionOrigin: anchor ?? Rect.fromCenter( center: MediaQuery.sizeOf(context).center(Offset.zero), width: 1, height: 1, ), ), ); break; case 'album': await _goToCurrentAlbum(mediaItem: mediaItem, source: source); break; case 'details': _showDetailsSheet(context, colorScheme); break; case 'sleepTimer': await _showSleepTimerSheet(context, controller, colorScheme); break; case 'external': await _openExternally(source); break; } } Future _showSleepTimerSheet( BuildContext context, MusicPlayerController controller, ColorScheme colorScheme, ) async { const durations = [15, 30, 45, 60]; final isActive = controller.sleepTimerEndsAt != null; final selection = await showAppBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, title: context.l10n.nowPlayingSleepTimer, builder: (sheetContext) => Padding( padding: const EdgeInsets.only(bottom: 16), child: SettingsGroup( children: [ for (final minutes in durations) SettingsItem( icon: Icons.timer_outlined, title: sheetContext.l10n.nowPlayingSleepTimerMinutes(minutes), showDivider: isActive || minutes != durations.last, onTap: () => Navigator.of(sheetContext).pop('$minutes'), ), if (isActive) SettingsItem( icon: Icons.timer_off_outlined, title: sheetContext.l10n.nowPlayingSleepTimerOff, showDivider: false, onTap: () => Navigator.of(sheetContext).pop('off'), ), ], ), ), ); if (!mounted || !context.mounted || selection == null) return; if (selection == 'off') { controller.cancelSleepTimer(); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.nowPlayingSleepTimerCancelled)), ); return; } final minutes = int.tryParse(selection); if (minutes == null) return; controller.setSleepTimer(Duration(minutes: minutes)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.nowPlayingSleepTimerSet( context.l10n.nowPlayingSleepTimerMinutes(minutes), ), ), ), ); } Future _goToCurrentAlbum({ required MediaItem mediaItem, required String source, }) async { final albumName = (mediaItem.album ?? '').trim(); if (albumName.isEmpty) return; // Prefer the stored collection so this action remains useful offline and // opens the exact files the user is currently playing. try { final historyItem = source.isEmpty ? null : await ref .read(downloadHistoryProvider.notifier) .getByFilePathAsync(source); if (!mounted) return; if (historyItem != null) { final albumArtist = (historyItem.albumArtist ?? '').trim(); pushViaPreferredNavigator( context, (_) => DownloadedAlbumScreen( albumName: historyItem.albumName, artistName: albumArtist.isNotEmpty ? albumArtist : historyItem.artistName, coverUrl: historyItem.coverUrl, ), ); return; } } catch (e) { _log.w('Failed to resolve downloaded album: $e'); } try { final row = await LibraryDatabase.instance.getById(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; } } } catch (e) { _log.w('Failed to resolve local album: $e'); } if (!mounted) return; await navigateToAlbum( context, albumName: albumName, artistName: mediaItem.artist, coverUrl: mediaItem.artUri?.toString(), ); } 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 (!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; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( context.l10n.nowPlayingShuffleLibraryFailed( context.friendlyError(e), ), ), ), ); } } void _showQueueSheet(ColorScheme colorScheme) { if (context.isMornye) { _setMornyePage(2); return; } if (_queueSheetShowing) return; _queueSheetShowing = true; showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, backgroundColor: colorScheme.surfaceContainerHigh, builder: (context) { return DraggableScrollableSheet( expand: false, initialChildSize: 0.6, maxChildSize: 0.9, minChildSize: 0.4, builder: (context, scrollController) { return Consumer( builder: (context, ref, _) { final queue = ref.watch(playQueueProvider).value ?? const []; final current = ref.watch(currentMediaItemProvider).value; final controller = ref.read(musicPlayerControllerProvider); final shuffleOn = ref.watch( playbackStateProvider.select( (s) => s.value?.shuffleMode == AudioServiceShuffleMode.all, ), ); final repeatMode = ref.watch( playbackStateProvider.select( (s) => s.value?.repeatMode ?? AudioServiceRepeatMode.none, ), ); final textTheme = Theme.of(context).textTheme; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(24, 4, 16, 8), child: Row( children: [ Text( context.l10n.nowPlayingUpNext, style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), ), const Spacer(), IconButton( tooltip: switch (repeatMode) { AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, _ => context.l10n.nowPlayingRepeatAll, }, isSelected: repeatMode != AudioServiceRepeatMode.none, icon: Icon( repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat, ), color: repeatMode != AudioServiceRepeatMode.none ? colorScheme.primary : null, onPressed: () => controller.setRepeatMode(switch (repeatMode) { AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, _ => AudioServiceRepeatMode.none, }), ), IconButton( tooltip: shuffleOn ? context.l10n.nowPlayingShuffleOn : context.l10n.nowPlayingPlayInOrder, isSelected: shuffleOn, icon: const Icon(Icons.shuffle), color: shuffleOn ? colorScheme.primary : null, onPressed: () => controller.setShuffle(!shuffleOn), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), child: SizedBox( width: double.infinity, child: FilledButton.tonalIcon( onPressed: () => _shuffleLibrary(controller), icon: const Icon(Icons.shuffle, size: 18), label: Text(context.l10n.nowPlayingShuffleLibrary), ), ), ), if (queue.isEmpty) Expanded( child: Center( child: Text( context.l10n.nowPlayingQueueEmpty, style: textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ) else Expanded( child: ReorderableListView.builder( scrollController: scrollController, padding: const EdgeInsets.fromLTRB(8, 0, 8, 24), itemCount: queue.length, onReorderItem: (oldIndex, newIndex) { controller.moveQueueItem(oldIndex, newIndex); }, proxyDecorator: (child, index, animation) { return Material( elevation: 4, color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), child: child, ); }, itemBuilder: (context, i) { final item = queue[i]; final isCurrent = current?.id == item.id; return PlayerQueueDismissible( key: ObjectKey(item), enabled: !isCurrent, onRemove: () => controller.removeQueuedItem(item), child: ListTile( contentPadding: const EdgeInsets.only( left: 16, right: 4, ), leading: Icon( isCurrent ? Icons.equalizer : Icons.music_note, color: isCurrent ? colorScheme.primary : colorScheme.onSurfaceVariant, ), title: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodyLarge?.copyWith( fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, color: isCurrent ? colorScheme.primary : colorScheme.onSurface, ), ), subtitle: Text( item.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), trailing: ReorderableDragStartListener( index: i, child: Padding( padding: const EdgeInsets.all(8), child: Icon( Icons.drag_handle, color: colorScheme.onSurfaceVariant, ), ), ), onTap: () => controller.jumpTo(i), ), ); }, ), ), ], ); }, ); }, ); }, ).whenComplete(() => _queueSheetShowing = false); } 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; }); showModalBottomSheet( context: context, isScrollControlled: true, useRootNavigator: mornye, showDragHandle: !mornye, backgroundColor: mornye ? Colors.transparent : colorScheme.surfaceContainerHigh, builder: (context) { return DraggableScrollableSheet( expand: false, initialChildSize: mornye ? 0.75 : 0.6, maxChildSize: 0.9, minChildSize: 0.4, builder: (context, scrollController) { return FutureBuilder>( future: metadataFuture, initialData: fallback, builder: (context, snapshot) => _MetadataList( meta: snapshot.data ?? fallback, colorScheme: colorScheme, scrollController: scrollController, mediaItem: mediaItem, ), ); }, ); }, ); } } enum _PlayerHeaderSlot { stage, header } /// Keep one live header throughout the cover/lyrics/queue transition. Measuring /// it at its current width also accommodates accessibility text sizes without /// moving the transport controls below this layout. class _PlayerHeaderLayout extends MultiChildLayoutDelegate { _PlayerHeaderLayout({ required this.progress, required this.compactHeight, required this.compactLeft, }); final double progress; final double compactHeight; final double compactLeft; @override void performLayout(Size size) { final left = 28 + (compactLeft - 28) * progress; // Align the visible ellipsis with the timeline while leaving its touch // target wider than the glyph and interpolating the same live header. const compactRight = _mornyeContentInset - (_mornyeHeaderButtonSize - _mornyeCompactIconSize) / 2; final right = 28 + (compactRight - 28) * progress; final header = layoutChild( _PlayerHeaderSlot.header, BoxConstraints( minWidth: size.width - left - right, maxWidth: size.width - left - right, minHeight: compactHeight * progress, ), ); final stageHeight = (size.height - (header.height + 20) * (1 - progress)) .clamp(0.0, size.height); layoutChild( _PlayerHeaderSlot.stage, BoxConstraints.tight(Size(size.width, stageHeight)), ); positionChild(_PlayerHeaderSlot.stage, Offset.zero); positionChild( _PlayerHeaderSlot.header, Offset( left, (size.height - header.height - 8) * (1 - progress) + 8 * progress, ), ); } @override bool shouldRelayout(_PlayerHeaderLayout oldDelegate) => progress != oldDelegate.progress || compactHeight != oldDelegate.compactHeight || compactLeft != oldDelegate.compactLeft; } class _PlaybackControls extends ConsumerWidget { final String mediaId; final Duration duration; final MusicPlayerController controller; final PlaybackSeekPreview seekPreview; final ColorScheme colorScheme; final String? qualityLabel; final bool compact; final double transportTopPadding; const _PlaybackControls({ super.key, required this.mediaId, required this.duration, required this.controller, required this.seekPreview, required this.colorScheme, required this.qualityLabel, this.compact = false, this.transportTopPadding = 16, }); @override Widget build(BuildContext context, WidgetRef ref) { final mornye = context.isMornye; final isPlaying = ref.watch(playbackPlayingProvider); final isLoading = ref.watch(playbackLoadingProvider); final playPauseSize = compact ? (isPlaying ? 44.0 : 40.0) : (isPlaying ? 60.0 : 54.0); final timeStyle = Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant); final shuffleOn = ref.watch( playbackStateProvider.select( (s) => s.value?.shuffleMode == AudioServiceShuffleMode.all, ), ); final repeatMode = ref.watch( playbackStateProvider.select( (s) => s.value?.repeatMode ?? AudioServiceRepeatMode.none, ), ); return Column( children: [ ValueListenableBuilder( valueListenable: seekPreview, builder: (context, preview, _) => Consumer( builder: (context, ref, _) { final position = ref.watch(playbackPositionProvider); final elapsedSeconds = (preview ?? position).inSeconds; return Padding( padding: EdgeInsets.symmetric( horizontal: mornye ? _mornyeTimelinePadding : 16, ), child: Column( children: [ SliderTheme( data: SliderThemeData( trackHeight: 4, activeTrackColor: mornye ? colorScheme.onSurface : colorScheme.primary, inactiveTrackColor: colorScheme.onSurface.withValues( alpha: 0.18, ), thumbColor: mornye ? colorScheme.onSurface : colorScheme.primary, // A 7dp thumb was hard to grab; 10dp with a 24dp overlay // gives the drag gesture a full-size target. thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 10, ), overlayShape: const RoundSliderOverlayShape( overlayRadius: 24, ), ), child: PlaybackSeekSlider( key: ValueKey(mediaId), position: position, duration: duration, onSeek: controller.seek, preview: seekPreview, ), ), Padding( padding: EdgeInsets.symmetric( horizontal: mornye ? MornyePlayerSlider.horizontalInset : 12, ), child: Row( children: [ if (mornye) MornyePlaybackTime( key: ValueKey('elapsed:$mediaId'), seconds: elapsedSeconds, style: timeStyle, ) else Text(formatClock(elapsedSeconds), style: timeStyle), Expanded( child: Center( child: _QualityBadge( label: qualityLabel, colorScheme: colorScheme, ), ), ), if (mornye) MornyePlaybackTime( key: ValueKey('remaining:$mediaId'), // Subtract whole seconds so both labels roll together, // even when the track duration includes milliseconds. seconds: (duration.inSeconds - elapsedSeconds) .clamp(0, duration.inSeconds), remaining: true, style: timeStyle, ) else Text( formatClock(duration.inSeconds), style: timeStyle, ), ], ), ), ], ), ); }, ), ), SizedBox(height: compact ? 8 : transportTopPadding), Padding( padding: EdgeInsets.symmetric( horizontal: mornye ? (compact ? 16 : 32) : 0, ), child: Row( mainAxisAlignment: mornye ? MainAxisAlignment.spaceEvenly : MainAxisAlignment.center, children: [ if (!mornye) IconButton( iconSize: 24, tooltip: shuffleOn ? context.l10n.nowPlayingShuffleOn : context.l10n.nowPlayingPlayInOrder, color: shuffleOn ? colorScheme.primary : colorScheme.onSurfaceVariant, icon: const Icon(Icons.shuffle), onPressed: () => controller.setShuffle(!shuffleOn), ), if (!mornye) const SizedBox(width: 8), if (mornye) MornyePlaybackButton( icon: CupertinoIcons.backward_fill, color: colorScheme.onSurface, tooltip: context.l10n.nowPlayingPreviousTrack, onPressed: controller.previous, ) else IconButton( iconSize: 44, color: colorScheme.onSurface, tooltip: context.l10n.nowPlayingPreviousTrack, icon: const Icon(Icons.skip_previous), onPressed: controller.previous, ), if (!mornye) SizedBox(width: context.tokens.playerControlGap), if (mornye) MornyePlaybackButton( icon: isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, color: colorScheme.onSurface, iconSize: playPauseSize, // A smaller play glyph keeps the same 68dp touch target and // does not move the timeline, artwork or adjacent buttons. padding: EdgeInsets.all((68 - playPauseSize) / 2), loading: isLoading, onPressed: () => controller.togglePlayPause(isPlaying), ) else Container( decoration: BoxDecoration( color: colorScheme.primary, shape: BoxShape.circle, ), child: IconButton( iconSize: 44, padding: const EdgeInsets.all(12), color: colorScheme.onPrimary, tooltip: isPlaying ? context.l10n.actionPause : context.l10n.tooltipPlay, icon: isLoading ? const SizedBox.square( dimension: 32, child: CircularProgressIndicator(strokeWidth: 3), ) : Icon(isPlaying ? Icons.pause : Icons.play_arrow), onPressed: isLoading ? null : () => controller.togglePlayPause(isPlaying), ), ), if (!mornye) SizedBox(width: context.tokens.playerControlGap), if (mornye) MornyePlaybackButton( icon: CupertinoIcons.forward_fill, color: colorScheme.onSurface, tooltip: context.l10n.nowPlayingNextTrack, onPressed: controller.next, ) else IconButton( iconSize: 44, color: colorScheme.onSurface, tooltip: context.l10n.nowPlayingNextTrack, icon: const Icon(Icons.skip_next), onPressed: controller.next, ), if (!mornye) const SizedBox(width: 8), if (!mornye) IconButton( iconSize: 24, tooltip: switch (repeatMode) { AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, _ => context.l10n.nowPlayingRepeatAll, }, color: repeatMode == AudioServiceRepeatMode.none ? colorScheme.onSurfaceVariant : colorScheme.primary, icon: Icon( repeatMode == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat, ), onPressed: () => controller.setRepeatMode( switch (repeatMode) { AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, _ => AudioServiceRepeatMode.none, }, ), ), ], ), ), ], ); } } 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> { 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; // 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(); _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() { _hasStarted = false; _lineExtents = null; _lineLayoutKey = null; _lines = lyricsTimelineWithGaps(widget.lyrics.lines); _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) return; _userScrollIdleTimer?.cancel(); _userScrolling = false; final position = _displayPosition; _playing = ref.read(playbackPlayingProvider); _loading = ref.read(playbackLoadingProvider); _active = _activeIndexAt(position); _activeLines = activeLyricIndices(_lines, position, _active); _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; }); 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(); 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); final extents = _lineExtents; 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 { await _scroll.animateTo( offset, duration: duration, curve: Curves.easeOutCubic, ); } return; } if (index < _lineKeys.length) { final lineContext = _lineKeys[index].currentContext; if (lineContext != null) { 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 { 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 loading = ref.watch(playbackLoadingProvider); final mornye = context.isMornye; final highContrast = MediaQuery.highContrastOf(context); final blurLyrics = mornye && !highContrast && (!ref.watch(lowEndDeviceProvider) || ref.watch(backdropBlurEnabledProvider)); final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 280); return NotificationListener( onNotification: (notification) { if (notification.direction != ScrollDirection.idle) { _userScrolling = true; _userScrollIdleTimer?.cancel(); _userScrollIdleTimer = Timer(const Duration(seconds: 4), () { if (!mounted) 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. _lineExtents = 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; }); if (previousExtents != null && _layoutVisibility != null && _layoutVisibility != visibility && 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; 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 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 Padding( key: _lineKeys[index], 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), ); } return Padding( key: _lineKeys[index], 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, ), ), ), ); }, ); }, ), ); } 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; 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; final boxes = []; // Keep paragraph shaping/wrapping intact. Select whole graphemes so // accents, surrogate pairs and joined emoji never lift in pieces. final fragments = widget.liftEnabled ? segment.characters : [segment]; var offset = segmentOffset; for (final fragment in fragments) { boxes.addAll( highlightedPainter .getBoxesForSelection( TextSelection( baseOffset: offset, extentOffset: offset + fragment.length, ), boxHeightStyle: BoxHeightStyle.max, ) .map((box) => box.toRect()), ); offset += fragment.length; } 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); var consumed = 0.0; for (final box in boxes) { if (box.width <= 0) continue; final lift = highlightLift > 0 && timed ? highlightLift * syncedLyricSegmentLift( position: position, start: starts[index], end: ends[index], progressOffset: consumed / width, ) : 0.0; if (highlightLift > 0) { pendingPaths.putIfAbsent(lift, Path.new).addRect(box); } // Consume the same word progress across graphemes, wrapping and font // fallback. The sweep timing never restarts at a glyph boundary. 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) { // The feather can cross into the following grapheme before its solid // fill arrives, avoiding a hard flash at each letter boundary. 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; final ScrollController scrollController; final MediaItem? mediaItem; const _MetadataList({ required this.meta, required this.colorScheme, required this.scrollController, this.mediaItem, }); @override Widget build(BuildContext context) { String s(Object? v) => (v ?? '').toString(); final l10n = context.l10n; final rows = <(String, String)>[ (l10n.editMetadataFieldTitle, s(meta['title'])), (l10n.editMetadataFieldArtist, s(meta['artist'])), (l10n.editMetadataFieldAlbum, s(meta['album'])), (l10n.editMetadataFieldAlbumArtist, s(meta['album_artist'])), (l10n.editMetadataFieldGenre, s(meta['genre'])), (l10n.editMetadataFieldComposer, s(meta['composer'])), (l10n.editMetadataFieldDate, s(meta['date'])), (l10n.editMetadataFieldTrackNum, s(meta['track_number'])), (l10n.editMetadataFieldDiscNum, s(meta['disc_number'])), (l10n.editMetadataFieldIsrc, formatIsrcForDisplay(s(meta['isrc']))), (l10n.editMetadataFieldLabel, s(meta['label'])), (l10n.editMetadataFieldCopyright, s(meta['copyright'])), (l10n.libraryFilterFormat, s(meta['format']).toUpperCase()), (l10n.audioAnalysisCodec, s(meta['audio_codec'])), ( l10n.audioAnalysisSampleRate, meta['sample_rate'] != null && (meta['sample_rate'] as num? ?? 0) > 0 ? '${((meta['sample_rate'] as num) / 1000).toStringAsFixed(1)} kHz' : '', ), ( l10n.audioAnalysisBitDepth, (meta['bit_depth'] as num? ?? 0) > 0 ? '${meta['bit_depth']}-bit' : '', ), ].where((r) => r.$2.trim().isNotEmpty && r.$2 != '0').toList(); if (context.isMornye) { return MornyePlayerDetailsSheet( rows: rows, scrollController: scrollController, mediaItem: mediaItem, ); } final textTheme = Theme.of(context).textTheme; return ListView( controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), children: [ Card( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), side: BorderSide( color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), ), child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.info_outline, size: 20, color: colorScheme.primary, ), const SizedBox(width: 8), Text( context.l10n.nowPlayingDetails, style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), ), ], ), const SizedBox(height: 16), ...rows.map( (row) => Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 4, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( row.$1, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( row.$2, style: textTheme.bodyMedium?.copyWith( color: colorScheme.onSurface, ), ), ), ], ), ), ), ], ), ), ), ], ); } } class _PageTabBar extends StatelessWidget { final PageController controller; final ColorScheme colorScheme; final List labels; const _PageTabBar({ required this.controller, required this.colorScheme, required this.labels, }); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: controller, builder: (context, _) { double page = 0; if (controller.hasClients && controller.position.haveDimensions) { page = controller.page ?? controller.initialPage.toDouble(); } return LayoutBuilder( builder: (context, constraints) { final tabWidth = constraints.maxWidth / labels.length; final indicatorWidth = (tabWidth * 0.5).clamp(28.0, 80.0); final base = Theme.of(context).textTheme.labelLarge ?? const TextStyle(); return SizedBox( height: 38, child: Stack( children: [ Row( children: List.generate(labels.length, (i) { // Distance of this tab from the current page position, // used to interpolate color/weight as the user swipes. final t = (1.0 - (page - i).abs()).clamp(0.0, 1.0); return Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => controller.animateToPage( i, duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic, ), child: Center( child: Text( labels[i], style: base.copyWith( fontWeight: FontWeight.lerp( FontWeight.w500, FontWeight.bold, t, ), color: Color.lerp( colorScheme.onSurfaceVariant.withValues( alpha: 0.55, ), colorScheme.primary, t, ), ), ), ), ), ); }), ), // Sliding underline that tracks the swipe in real time. Positioned( bottom: 0, left: page.clamp(0, (labels.length - 1).toDouble()) * tabWidth + (tabWidth - indicatorWidth) / 2, child: Container( width: indicatorWidth, height: 3, decoration: BoxDecoration( color: colorScheme.primary, borderRadius: BorderRadius.circular(2), ), ), ), ], ), ); }, ); }, ); } } class _QualityBadge extends StatelessWidget { final String? label; final ColorScheme colorScheme; const _QualityBadge({required this.label, required this.colorScheme}); @override Widget build(BuildContext context) { final text = label; if (text == null || text.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: context.isMornye ? null : BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.graphic_eq, size: 11, color: colorScheme.onSurfaceVariant), const SizedBox(width: 5), Flexible( child: Text( text, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( fontSize: 10.5, color: colorScheme.onSurfaceVariant, letterSpacing: 0.2, ), ), ), ], ), ); } }