diff --git a/lib/main.dart b/lib/main.dart index c60c3812..af520657 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -124,7 +124,11 @@ void main() { final navigator = AppNavigationService.rootNavigatorKey.currentState; if (navigator != null && handler.mediaItem.value != null) { - unawaited(navigator.push(NowPlayingRoute())); + unawaited( + navigator.push( + NowPlayingRoute(child: const NowPlayingScreen()), + ), + ); } } else { await PlayerWidgetService.control(handler, command); diff --git a/lib/routes/now_playing_route.dart b/lib/routes/now_playing_route.dart new file mode 100644 index 00000000..7e6f1cb7 --- /dev/null +++ b/lib/routes/now_playing_route.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/app_orientation.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +/// 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'; + +/// 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({required this.child, this.miniPlayerGeometry}) + : super( + fullscreenDialog: true, + settings: const RouteSettings(name: fullPlayerRouteName), + ); + + final Widget child; + + /// 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; + + bool get hasDismissArtwork => _dismissArtwork != null; + ({Rect surface, Rect artwork})? _dismissTarget; + ({Rect bounds, Widget child, bool fullBleed})? _dismissArtwork; + Rect? _dismissStart; + double _dismissStartValue = 1; + + bool _interactiveTransition = false; + int _dragGeneration = 0; + + // The player paints an opaque surface over the whole screen, including the + // status bar and bottom safe area. Once settled, the framework therefore + // keeps the pages below offstage: their glass, marquees and tickers stop + // compositing. Every drag or dismissal moves the animation off `completed`, + // which makes this route translucent again before the page is exposed. + @override + bool get opaque => true; + + /// Overlay size when the pages below were last laid out, recorded while this + /// route hides them. A rotation while hidden leaves their geometry stale. + Size? _coveredOverlaySize; + + Size? get _overlaySize { + final box = navigator?.overlay?.context.findRenderObject(); + return box is RenderBox && box.hasSize ? box.size : null; + } + + @override + void install() { + super.install(); + animation!.addStatusListener((status) { + _coveredOverlaySize = status.isCompleted ? _overlaySize : null; + }); + } + + @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 coveredSize = _coveredOverlaySize; + final target = coveredSize == null || coveredSize == _overlaySize + ? miniPlayerGeometry?.call() + : null; + 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: child); + } + + @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, + ); + } +} diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index b76fd614..b78fbc32 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; @@ -13,319 +12,40 @@ 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/settings_provider.dart'; +import 'package:spotiflac_android/routes/now_playing_route.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; -import 'package:spotiflac_android/services/app_orientation.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/isrc_utils.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/widgets/app_bottom_sheet.dart'; -import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; -import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:spotiflac_android/widgets/expressive_icon_button.dart'; -import 'package:spotiflac_android/widgets/automix_status_badge.dart'; -import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; import 'package:spotiflac_android/widgets/lyrics_screen_awake.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; -import 'package:spotiflac_android/widgets/player_lyrics.dart'; -import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; -import 'package:spotiflac_android/widgets/player_track_swipe.dart'; -import 'package:spotiflac_android/widgets/overflow_marquee.dart'; -import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; +import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; +import 'package:spotiflac_android/widgets/player_queue_sheet.dart'; +import 'package:spotiflac_android/widgets/player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/material_player_layout.dart'; +import 'package:spotiflac_android/widgets/mornye_player_layout.dart'; +import 'package:spotiflac_android/widgets/player_lyrics_controls.dart'; +import 'package:spotiflac_android/widgets/player_lyrics_section.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'; + +export 'package:spotiflac_android/routes/now_playing_route.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 _mornyeTimelinePadding = 28.0; -const _mornyeContentInset = mornyeLyricsContentInset; -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; - - // The player paints an opaque surface over the whole screen, including the - // status bar and bottom safe area. Once settled, the framework therefore - // keeps the pages below offstage: their glass, marquees and tickers stop - // compositing. Every drag or dismissal moves the animation off `completed`, - // which makes this route translucent again before the page is exposed. - @override - bool get opaque => true; - - /// Overlay size when the pages below were last laid out, recorded while this - /// route hides them. A rotation while hidden leaves their geometry stale. - Size? _coveredOverlaySize; - - Size? get _overlaySize { - final box = navigator?.overlay?.context.findRenderObject(); - return box is RenderBox && box.hasSize ? box.size : null; - } - - @override - void install() { - super.install(); - animation!.addStatusListener((status) { - _coveredOverlaySize = status.isCompleted ? _overlaySize : null; - }); - } - - @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 coveredSize = _coveredOverlaySize; - final target = coveredSize == null || coveredSize == _overlaySize - ? miniPlayerGeometry?.call() - : null; - 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 { @@ -355,8 +75,6 @@ class _NowPlayingScreenState extends ConsumerState { final _trackMetadata = PlayerMetadataController(); int _currentPage = 0; bool _landscape = false; - bool _bottomDragForwarding = false; - double _bottomDragTotal = 0; bool _queueSheetShowing = false; String? _measuredMotionSource; double? _motionAspectRatio; @@ -374,14 +92,13 @@ class _NowPlayingScreenState extends ConsumerState { final _expandedArtworkKey = GlobalKey(); final _compactArtworkKey = GlobalKey(); final _motionArtworkKey = GlobalKey(); - Map _artworkForeground = {}; - final _artworkColorsChanged = ValueNotifier(0); + final _artworkForeground = ValueNotifier>({}); final _seekPreview = PlaybackSeekPreview(); + NowPlayingRoute? _artworkRoute; @override void initState() { super.initState(); - _trackMetadata.addListener(_metadataChanged); _mediaItemSub = ref.listenManual>( currentMediaItemProvider, (previous, next) { @@ -409,10 +126,6 @@ class _NowPlayingScreenState extends ConsumerState { }); } - void _metadataChanged() { - if (mounted) setState(() {}); - } - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -426,25 +139,16 @@ class _NowPlayingScreenState extends ConsumerState { @override void dispose() { + _artworkRoute?.readArtwork = null; _lyricsIdleTimer?.cancel(); _mediaItemSub?.close(); _lyricsPlayingSub?.close(); _trackMetadata.dispose(); - _artworkColorsChanged.dispose(); + _artworkForeground.dispose(); _seekPreview.dispose(); super.dispose(); } - bool _isExplicit(MediaItem mediaItem) { - final source = mediaItem.extras?['source']?.toString(); - final loadedMetadata = source == _trackMetadata.source - ? _trackMetadata.metadata - : null; - return parseExplicitFlag(loadedMetadata?['explicit']) ?? - parseExplicitFlag(mediaItem.extras?['explicit']) ?? - false; - } - @override Widget build(BuildContext context) { return LyricsScreenAwake( @@ -464,7 +168,6 @@ class _NowPlayingScreenState extends ConsumerState { ).colorScheme : Theme.of(context).colorScheme; final mediaItem = ref.watch(currentMediaItemProvider).value; - final controller = ref.read(musicPlayerControllerProvider); if (mediaItem == null) { return Scaffold( @@ -502,7 +205,9 @@ class _NowPlayingScreenState extends ConsumerState { : null); final squareArtwork = motionArtwork == null || (motionRatio != null && motionRatio >= 0.95); - if (motionArtwork == null) _artworkForeground = {}; + if (motionArtwork == null && _artworkForeground.value.isNotEmpty) { + _artworkForeground.value = {}; + } Widget artwork() => MornyePlayerArtwork( mediaItem: mediaItem, videoUrl: motionArtwork?.source, @@ -529,8 +234,13 @@ class _NowPlayingScreenState extends ConsumerState { // delayed content fade would make dismissal appear to pause partway down. final route = ModalRoute.of(context); final routeAnimation = route?.animation ?? kAlwaysCompleteAnimation; + if (_artworkRoute != route) { + _artworkRoute?.readArtwork = null; + _artworkRoute = null; + } if (mornye && route is NowPlayingRoute) { - route._readArtwork = () { + _artworkRoute = route; + route.readArtwork = () { final key = _landscape ? _expandedArtworkKey : _currentPage != 0 @@ -634,20 +344,63 @@ class _NowPlayingScreenState extends ConsumerState { children: [ Expanded( child: mornye - ? _mornyePlayerPage( - mediaItem, - controller, - colorScheme, - motionArtwork: artwork(), - artworkAspectRatio: motionRatio, - insetArtwork: motionArtwork == null, + ? NotificationListener( + onNotification: _onLyricsScroll, + child: MornyePlayerLayout( + mediaItem: mediaItem, + metadata: _trackMetadata, + seekPreview: _seekPreview, + colorScheme: colorScheme, + page: _currentPage, + motionArtwork: artwork(), + artworkAspectRatio: motionRatio, + insetArtwork: motionArtwork == null, + artworkTopInset: + MediaQuery.paddingOf(context).top + 36, + artworkKeys: ( + header: _artworkHeaderKey, + controls: _artworkControlsKey, + volume: _artworkVolumeKey, + expanded: _expandedArtworkKey, + compact: _compactArtworkKey, + ), + artworkForeground: _artworkForeground, + lyricsControlsHidden: _lyricsControlsHidden, + lyricsOptionsOpen: _lyricsOptionsOpen, + lyricsBuilder: (showOptions) => _lyricsSection( + colorScheme, + isActive: _currentPage == 1, + showOptions: showOptions, + ), + lyricsOptions: _lyricsOptionsButton(colorScheme), + onPageChanged: _setMornyePage, + onTrackNavigation: _showTrackNavigationMenu, + onMoreActions: (buttonContext, item, anchor) => + _showMoreActions( + context: buttonContext, + mediaItem: item, + source: + item.extras?['source']?.toString() ?? '', + colorScheme: colorScheme, + anchor: anchor, + ), + ), ) - : _playerPage(mediaItem, controller, colorScheme), + : MaterialPlayerLayout( + mediaItem: mediaItem, + metadata: _trackMetadata, + seekPreview: _seekPreview, + colorScheme: colorScheme, + showLyrics: _currentPage == 1, + lyricsBuilder: (_) => + _lyricsSection(colorScheme, isActive: true), + onOpenQueue: () => _showQueueSheet(colorScheme), + ), ), _autoHidingLyricsControls( - _queueSwipeRegion( - colorScheme, - Column( + PlayerQueueSwipeRegion( + onOpenQueue: () => _showQueueSheet(colorScheme), + child: Column( mainAxisSize: MainAxisSize.min, children: [ if (mornye && !_landscape) @@ -751,8 +504,7 @@ class _NowPlayingScreenState extends ConsumerState { }, onChanged: (colors) { if (!mounted) return; - _artworkForeground = colors; - _artworkColorsChanged.value++; + _artworkForeground.value = colors; }, child: MornyePlayerBackground( artUri: mediaItem.artUri, @@ -760,9 +512,12 @@ class _NowPlayingScreenState extends ConsumerState { artworkAspectRatio: motionRatio, artwork: motionArtwork != null && !_landscape && _currentPage == 0 - ? _transitionArtwork( - _motionArtworkKey, - Hero(tag: kNowPlayingArtworkHeroTag, child: artwork()), + ? PlayerTransitionArtwork( + artworkKey: _motionArtworkKey, + child: Hero( + tag: kNowPlayingArtworkHeroTag, + child: artwork(), + ), ) : null, ), @@ -792,203 +547,6 @@ class _NowPlayingScreenState extends ConsumerState { ); } - 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 on the player footer 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(), - ), - ), - ), - ), - ), - ), - ); - - Widget visual() => AnimatedSwitcher( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 300), - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - child: _currentPage == 1 - ? KeyedSubtree( - key: const ValueKey('material-player-lyrics'), - child: _lyricsSection(colorScheme, isActive: true), - ) - : LayoutBuilder( - key: const ValueKey('material-player-cover'), - builder: (context, area) => artworkAt( - math.max( - 0.0, - math.min( - 360.0, - math.min(area.maxWidth - 64, area.maxHeight - 24), - ), - ), - ), - ), - ); - - // 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) { - return Row( - children: [ - Expanded(child: visual()), - 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, - compact: true, - ), - ), - ), - ), - ), - ], - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - children: [ - Expanded(child: visual()), - const SizedBox(height: 12), - _queueSwipeRegion( - colorScheme, - Column( - mainAxisSize: MainAxisSize.min, - children: _metadataAndControls( - mediaItem, - controller, - colorScheme, - ), - ), - ), - ], - ), - ); - }, - ); - } - void _toggleMaterialLyrics() { setState(() => _currentPage = _currentPage == 1 ? 0 : 1); if (_currentPage == 1) { @@ -1058,760 +616,54 @@ class _NowPlayingScreenState extends ConsumerState { _scheduleLyricsControlsHide(); } - Duration get _lyricsControlsMotion => MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 220); - - Widget _autoHidingLyricsControls(Widget child) { - if (!context.isMornye) return child; - final hidden = _currentPage == 1 && _lyricsControlsHidden && !_landscape; - // Fade in place: resizing controls also resizes the lyric viewport and can - // interrupt its active drag or trigger a playback auto-scroll correction. - return IgnorePointer( - ignoring: hidden, - child: ExcludeSemantics( - excluding: hidden, - child: AnimatedOpacity( - opacity: hidden ? 0 : 1, - duration: _lyricsControlsMotion, - curve: Curves.easeOutCubic, - child: child, - ), - ), - ); - } - - Widget _mornyeLyricsViewport(Widget child, {required double visibleHeight}) => - TweenAnimationBuilder( - tween: Tween(begin: 0, end: _lyricsControlsHidden ? 1 : 0), - duration: _lyricsControlsMotion, - curve: Curves.easeOutCubic, - builder: (context, reveal, child) => ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) { - final height = bounds.height.clamp(1.0, double.infinity); - final end = visibleHeight.clamp(0.0, height); - final start = (end - 24).clamp(0.0, end); - return LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, start / height, end / height, 1], - colors: [ - Colors.white, - Colors.white, - Colors.white.withValues(alpha: reveal), - Colors.white.withValues(alpha: reveal), - ], - ).createShader(bounds); - }, - child: child, - ), - child: child, - ); - - 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), - ); + bool _onLyricsScroll(ScrollUpdateNotification notification) { + if (_currentPage != 1 || + _landscape || + notification.dragDetails == null || + notification.metrics.axis != Axis.vertical) { + return false; } - - 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, - ) - : _mornyeLyricsViewport( - _lyricsSection( - colorScheme, - isActive: showLyrics, - showOptions: false, - ), - visibleHeight: - stage.minHeight - - (8 + compactHeaderHeight + 16), - ), - ) - : 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: _trackMetadata.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), - controls: controls(), - volume: const MornyeVolumeControl(), - ); - } - - final lyricsOptions = showLyrics - ? _lyricsOptionsButton(colorScheme) - : null; - Widget content() => TweenAnimationBuilder( - tween: Tween(begin: 0, end: compactStage ? 1 : 0), - duration: motion, - curve: Curves.easeInOutCubic, - builder: (context, progress, controls) => CustomMultiChildLayout( - delegate: _PlayerHeaderLayout( - progress: progress, - compactHeight: compactHeaderHeight, - compactLeft: _mornyeContentInset + compactCoverSize + 12, - expandLyrics: showLyrics, - ), - 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, - ), - ), - ), - ), - LayoutId(id: _PlayerHeaderSlot.controls, child: controls!), - if (lyricsOptions != null) - LayoutId( - id: _PlayerHeaderSlot.lyricsOptions, - child: _autoHidingLyricsControls(lyricsOptions), - ), - ], - ), - child: _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 = 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, - ); + 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; } - 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, { - bool compact = false, - }) { - 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: - (compact - ? Theme.of(context).textTheme.titleLarge - : Theme.of(context).textTheme.headlineSmall) - ?.copyWith( - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - maxLines: compact ? 1 : 2, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: compact ? 4 : 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, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - SizedBox(height: compact ? 12 : 24), - _PlaybackControls( - key: ValueKey(mediaItem.id), - mediaId: mediaItem.id, - duration: mediaItem.duration ?? Duration.zero, - controller: controller, - seekPreview: _seekPreview, - colorScheme: colorScheme, - qualityLabel: _trackMetadata.qualityLabel, - compact: compact, - ), - ]; - } + Widget _autoHidingLyricsControls(Widget child) => context.isMornye + ? PlayerLyricsControlsVisibility( + hidden: _currentPage == 1 && _lyricsControlsHidden && !_landscape, + child: child, + ) + : child; Widget _lyricsSection( ColorScheme colorScheme, { required bool isActive, bool showOptions = true, - }) { - if (_trackMetadata.loading) { - return const Center(child: AppLoadingIndicator()); - } - if (_trackMetadata.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 (_trackMetadata.lyrics.synced) { - final visibility = ref.watch( - settingsProvider.select( - (settings) => ( - settings.playerShowPronunciation, - settings.playerShowTranslation, - ), - ), - ); - final options = showOptions && isActive && !_landscape - ? _lyricsOptionsButton(colorScheme) - : null; - return Stack( - fit: StackFit.expand, - children: [ - SyncedLyricsView( - lyrics: _trackMetadata.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( - _trackMetadata.lyrics.plainText, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - height: 1.6, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ), - ?_lyricsCredits(), - ], - ), - ); - } + }) => PlayerLyricsSection( + metadata: _trackMetadata, + seekPreview: _seekPreview, + colorScheme: colorScheme, + isActive: isActive, + options: showOptions && isActive && !_landscape + ? _autoHidingLyricsControls(_lyricsOptionsButton(colorScheme)) + : null, + ); - LyricsCredits? _lyricsCredits() { - final (:writers, :provider) = _trackMetadata.credits; - if (writers == null && provider == null) return null; - return LyricsCredits(writers: writers, provider: provider); - } + Widget _lyricsOptionsButton(ColorScheme colorScheme) => ListenableBuilder( + listenable: _trackMetadata, + builder: (context, _) => + _buildLyricsOptionsButton(colorScheme) ?? const SizedBox.shrink(), + ); - Widget? _lyricsOptionsButton(ColorScheme colorScheme) { + Widget? _buildLyricsOptionsButton(ColorScheme colorScheme) { if (_trackMetadata.loading || !_trackMetadata.lyrics.synced) return null; final pronunciation = _trackMetadata.lyrics.lines.any( (line) => line.romanization?.trim().isNotEmpty == true, @@ -2110,7 +962,14 @@ class _NowPlayingScreenState extends ConsumerState { await _goToCurrentAlbum(mediaItem: mediaItem, source: source); break; case 'details': - _showDetailsSheet(context, colorScheme); + unawaited( + showPlayerDetailsSheet( + context, + colorScheme, + metadata: _trackMetadata, + mediaItem: ref.read(currentMediaItemProvider).value, + ), + ); break; case 'sleepTimer': await _showSleepTimerSheet(context, controller, colorScheme); @@ -2245,30 +1104,6 @@ class _NowPlayingScreenState extends ConsumerState { ); } - Future _shuffleLibrary(MusicPlayerController controller) async { - try { - if (!await controller.shuffleLibrary()) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)), - ); - return; - } - 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); @@ -2276,799 +1111,9 @@ class _NowPlayingScreenState extends ConsumerState { } 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; - final autoplay = ref.watch( - settingsProvider.select((s) => s.autoplay), - ); - - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 4, 16, 8), - child: Row( - children: [ - Expanded( - child: Text( - context.l10n.nowPlayingUpNext, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ), - ExpressiveIconButton( - tooltip: autoplay - ? context.l10n.autoplayOn - : context.l10n.autoplayOff, - selected: autoplay, - icon: const Icon(Icons.all_inclusive), - foregroundColor: autoplay - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant, - backgroundColor: autoplay - ? colorScheme.secondaryContainer - : null, - onPressed: () => ref - .read(settingsProvider.notifier) - .setAutoplay(!autoplay), - ), - const SizedBox(width: 4), - ExpressiveIconButton( - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.one => - context.l10n.nowPlayingRepeatOne, - AudioServiceRepeatMode.none => - context.l10n.nowPlayingRepeatOff, - _ => context.l10n.nowPlayingRepeatAll, - }, - selected: repeatMode != AudioServiceRepeatMode.none, - icon: Icon( - repeatMode == AudioServiceRepeatMode.one - ? Icons.repeat_one - : Icons.repeat, - ), - foregroundColor: - repeatMode != AudioServiceRepeatMode.none - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant, - backgroundColor: - repeatMode != AudioServiceRepeatMode.none - ? colorScheme.secondaryContainer - : null, - onPressed: () => - controller.setRepeatMode(switch (repeatMode) { - AudioServiceRepeatMode.none => - AudioServiceRepeatMode.all, - AudioServiceRepeatMode.all => - AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }), - ), - const SizedBox(width: 4), - ExpressiveIconButton( - tooltip: shuffleOn - ? context.l10n.nowPlayingShuffleOn - : context.l10n.nowPlayingPlayInOrder, - selected: shuffleOn, - icon: const Icon(Icons.shuffle), - foregroundColor: shuffleOn - ? colorScheme.onSecondaryContainer - : colorScheme.onSurfaceVariant, - backgroundColor: shuffleOn - ? colorScheme.secondaryContainer - : null, - onPressed: () => controller.setShuffle(!shuffleOn), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: SizedBox( - width: double.infinity, - child: ExpressiveButton( - tonal: true, - onPressed: () => _shuffleLibrary(controller), - icon: const Icon(Icons.shuffle, size: 18), - child: 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( - selected: isCurrent, - selectedTileColor: colorScheme - .secondaryContainer - .withValues(alpha: 0.55), - contentPadding: const EdgeInsets.only( - left: 16, - right: 4, - ), - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox.square( - dimension: 44, - child: PlayerArtwork( - artUri: item.artUri?.toString(), - colorScheme: colorScheme, - cacheWidth: 132, - iconSize: 22, - ), - ), - ), - 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), - ), - ); - }, - ), - ), - ], - ); - }, - ); - }, - ); - }, + showPlayerQueueSheet( + context, + colorScheme, ).whenComplete(() => _queueSheetShowing = false); } - - void _showDetailsSheet(BuildContext context, ColorScheme colorScheme) { - final mornye = context.isMornye; - final mediaItem = ref.read(currentMediaItemProvider).value; - final fallback = _trackMetadata.detailsSnapshot(mediaItem); - final metadataFuture = _trackMetadata.readDetails(mediaItem); - 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, controls, lyricsOptions } - -/// 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, - required this.expandLyrics, - }); - - final double progress; - final double compactHeight; - final double compactLeft; - final bool expandLyrics; - - @override - void performLayout(Size size) { - final controls = layoutChild( - _PlayerHeaderSlot.controls, - BoxConstraints.tightFor(width: size.width), - ); - final contentHeight = (size.height - controls.height).clamp( - 0.0, - size.height, - ); - positionChild(_PlayerHeaderSlot.controls, Offset(0, contentHeight)); - 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 = (contentHeight - (header.height + 20) * (1 - progress)) - .clamp(0.0, contentHeight); - layoutChild( - _PlayerHeaderSlot.stage, - // Lyrics always extend behind controls. The minimum height marks their - // unobscured area for the paint mask; hiding controls never changes either - // constraint, scroll padding or the Scrollable's identity. - BoxConstraints( - minWidth: size.width, - maxWidth: size.width, - minHeight: stageHeight, - maxHeight: - stageHeight + (expandLyrics ? controls.height * progress : 0), - ), - ); - positionChild(_PlayerHeaderSlot.stage, Offset.zero); - positionChild( - _PlayerHeaderSlot.header, - Offset( - left, - (contentHeight - header.height - 8) * (1 - progress) + 8 * progress, - ), - ); - if (hasChild(_PlayerHeaderSlot.lyricsOptions)) { - final options = layoutChild( - _PlayerHeaderSlot.lyricsOptions, - BoxConstraints.loose(size), - ); - positionChild( - _PlayerHeaderSlot.lyricsOptions, - Offset(_mornyeContentInset, contentHeight - options.height - 8), - ); - } - } - - @override - bool shouldRelayout(_PlayerHeaderLayout oldDelegate) => - progress != oldDelegate.progress || - compactHeight != oldDelegate.compactHeight || - compactLeft != oldDelegate.compactLeft || - expandLyrics != oldDelegate.expandLyrics; -} - -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, - playing: isPlaying && !isLoading, - ), - ), - if (!mornye) const SizedBox(height: 8), - 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: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _QualityBadge( - label: qualityLabel, - colorScheme: colorScheme, - ), - const AutoMixStatusBadge(), - ], - ), - ), - ), - 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 + (mornye ? 0 : 8)), - Padding( - padding: EdgeInsets.symmetric( - horizontal: mornye ? (compact ? 16 : 32) : 0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (!mornye) - ExpressiveIconButton( - iconSize: 24, - selected: shuffleOn, - tooltip: shuffleOn - ? context.l10n.nowPlayingShuffleOn - : context.l10n.nowPlayingPlayInOrder, - foregroundColor: shuffleOn - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - backgroundColor: shuffleOn - ? colorScheme.primaryContainer - : null, - icon: const Icon(Icons.shuffle), - onPressed: () => controller.setShuffle(!shuffleOn), - ), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.backward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - onPressed: controller.previous, - ) - else - ExpressiveIconButton( - iconSize: 44, - foregroundColor: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingPreviousTrack, - icon: const Icon(Icons.skip_previous), - onPressed: controller.previous, - ), - 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 - ExpressiveIconButton( - size: 68, - iconSize: 44, - selected: isPlaying, - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - tooltip: isPlaying - ? context.l10n.actionPause - : context.l10n.tooltipPlay, - icon: isLoading - ? AppLoadingIndicator(color: colorScheme.onSurfaceVariant) - : Icon(isPlaying ? Icons.pause : Icons.play_arrow), - onPressed: isLoading - ? null - : () => controller.togglePlayPause(isPlaying), - ), - if (mornye) - MornyePlaybackButton( - icon: CupertinoIcons.forward_fill, - color: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - onPressed: controller.next, - ) - else - ExpressiveIconButton( - iconSize: 44, - foregroundColor: colorScheme.onSurface, - tooltip: context.l10n.nowPlayingNextTrack, - icon: const Icon(Icons.skip_next), - onPressed: controller.next, - ), - if (!mornye) - ExpressiveIconButton( - iconSize: 24, - selected: repeatMode != AudioServiceRepeatMode.none, - tooltip: switch (repeatMode) { - AudioServiceRepeatMode.one => - context.l10n.nowPlayingRepeatOne, - AudioServiceRepeatMode.none => - context.l10n.nowPlayingRepeatOff, - _ => context.l10n.nowPlayingRepeatAll, - }, - foregroundColor: repeatMode == AudioServiceRepeatMode.none - ? colorScheme.onSurfaceVariant - : colorScheme.primary, - backgroundColor: repeatMode == AudioServiceRepeatMode.none - ? null - : colorScheme.primaryContainer, - 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 _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 _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(); - final mornye = context.isMornye; - return Container( - padding: EdgeInsets.symmetric( - horizontal: mornye ? 8 : 10, - vertical: mornye ? 2 : 5, - ), - decoration: mornye - ? null - : BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.graphic_eq, - size: mornye ? 11 : 14, - color: colorScheme.onSurfaceVariant, - ), - SizedBox(width: mornye ? 5 : 6), - Flexible( - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontSize: mornye ? 10.5 : 12, - color: colorScheme.onSurfaceVariant, - letterSpacing: 0.2, - ), - ), - ), - ], - ), - ); - } } diff --git a/lib/widgets/material_player_layout.dart b/lib/widgets/material_player_layout.dart new file mode 100644 index 00000000..0e048716 --- /dev/null +++ b/lib/widgets/material_player_layout.dart @@ -0,0 +1,231 @@ +import 'dart:math' as math; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/routes/now_playing_route.dart'; +import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; +import 'package:spotiflac_android/widgets/player_metadata_title.dart'; +import 'package:spotiflac_android/widgets/player_playback_controls.dart'; + +class MaterialPlayerLayout extends StatelessWidget { + const MaterialPlayerLayout({ + super.key, + required this.mediaItem, + required this.metadata, + required this.seekPreview, + required this.colorScheme, + required this.showLyrics, + required this.lyricsBuilder, + required this.onOpenQueue, + }); + + final MediaItem mediaItem; + final PlayerMetadataController metadata; + final PlaybackSeekPreview seekPreview; + final ColorScheme colorScheme; + final bool showLyrics; + final WidgetBuilder lyricsBuilder; + final VoidCallback onOpenQueue; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + Widget artworkAt(double artSize) => Center( + child: PlayerArtworkDragRegion( + child: 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(), + ), + ), + ), + ), + ), + ), + ); + + Widget visual() => AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + child: showLyrics + ? KeyedSubtree( + key: const ValueKey('material-player-lyrics'), + child: lyricsBuilder(context), + ) + : LayoutBuilder( + key: const ValueKey('material-player-cover'), + builder: (context, area) => artworkAt( + math.max( + 0.0, + math.min( + 360.0, + math.min(area.maxWidth - 64, area.maxHeight - 24), + ), + ), + ), + ), + ); + + // 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) { + return Row( + children: [ + Expanded(child: visual()), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: _metadataAndControls(context, compact: true), + ), + ), + ), + ), + ], + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + Expanded(child: visual()), + const SizedBox(height: 12), + PlayerQueueSwipeRegion( + onOpenQueue: onOpenQueue, + child: Column( + mainAxisSize: MainAxisSize.min, + children: _metadataAndControls(context), + ), + ), + ], + ), + ); + }, + ); + } + + /// Title/artist, seek slider, and transport buttons — shared by the + /// portrait column and the landscape right pane. + List _metadataAndControls( + BuildContext context, { + bool compact = false, + }) { + 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: [ + PlayerMetadataTitle( + mediaItem: mediaItem, + metadata: metadata, + style: + (compact + ? Theme.of(context).textTheme.titleLarge + : Theme.of(context).textTheme.headlineSmall) + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: compact ? 4 : 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, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + SizedBox(height: compact ? 12 : 24), + PlayerPlaybackControls( + key: ValueKey(mediaItem.id), + mediaId: mediaItem.id, + duration: mediaItem.duration ?? Duration.zero, + seekPreview: seekPreview, + colorScheme: colorScheme, + metadata: metadata, + compact: compact, + ), + ]; + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index ed0fb87b..f5a259ef 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -114,6 +114,7 @@ class _MiniPlayerState extends ConsumerState { onTap: () { Navigator.of(context, rootNavigator: true).push( NowPlayingRoute( + child: const NowPlayingScreen(), miniPlayerGeometry: mornye ? () { if (!mounted) return null; diff --git a/lib/widgets/mornye_player_layout.dart b/lib/widgets/mornye_player_layout.dart new file mode 100644 index 00000000..a90bcc75 --- /dev/null +++ b/lib/widgets/mornye_player_layout.dart @@ -0,0 +1,690 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/routes/now_playing_route.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.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_favorite_button.dart'; +import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; +import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; +import 'package:spotiflac_android/widgets/overflow_marquee.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; +import 'package:spotiflac_android/widgets/player_lyrics_controls.dart'; +import 'package:spotiflac_android/widgets/player_metadata_title.dart'; +import 'package:spotiflac_android/widgets/player_playback_controls.dart'; +import 'package:spotiflac_android/widgets/player_track_swipe.dart'; + +const _mornyeContentInset = mornyeLyricsContentInset; +const _mornyeHeaderButtonSize = 48.0; +const _mornyeCompactIconSize = 28.0; + +/// Keys stay owned by the surface that measures contrast and route dismissal. +typedef MornyePlayerArtworkKeys = ({ + GlobalKey header, + GlobalKey controls, + GlobalKey volume, + GlobalKey expanded, + GlobalKey compact, +}); + +class MornyePlayerLayout extends ConsumerWidget { + const MornyePlayerLayout({ + super.key, + required this.mediaItem, + required this.metadata, + required this.seekPreview, + required this.colorScheme, + required this.page, + required this.motionArtwork, + required this.insetArtwork, + required this.artworkTopInset, + required this.artworkKeys, + required this.artworkForeground, + required this.lyricsControlsHidden, + required this.lyricsOptionsOpen, + required this.lyricsBuilder, + required this.lyricsOptions, + required this.onPageChanged, + required this.onTrackNavigation, + required this.onMoreActions, + this.artworkAspectRatio, + }); + + final MediaItem mediaItem; + final PlayerMetadataController metadata; + final PlaybackSeekPreview seekPreview; + final ColorScheme colorScheme; + final int page; + final Widget motionArtwork; + final double? artworkAspectRatio; + final bool insetArtwork; + final double artworkTopInset; + final MornyePlayerArtworkKeys artworkKeys; + final ValueListenable> artworkForeground; + final bool lyricsControlsHidden; + final bool lyricsOptionsOpen; + final Widget Function(bool showOptions) lyricsBuilder; + final Widget? lyricsOptions; + final ValueChanged onPageChanged; + final Future Function(BuildContext, MediaItem) onTrackNavigation; + final Future Function(BuildContext, MediaItem, Rect?) onMoreActions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(musicPlayerControllerProvider); + final showLyrics = page == 1; + final showQueue = page == 2; + final compactStage = showLyrics || showQueue; + ColorScheme foreground(String region) { + final color = compactStage + ? null + : artworkForeground.value[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 landscape = + MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height; + Widget hideControls(Widget child) => PlayerLyricsControlsVisibility( + hidden: showLyrics && lyricsControlsHidden && !landscape, + child: child, + ); + final body = 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(page), + child: showQueue + ? MornyePlayerQueue( + colorScheme: colorScheme, + ) + : _mornyeLyricsViewport( + context, + lyricsBuilder(false), + visibleHeight: + stage.minHeight - + (8 + compactHeaderHeight + 16), + ), + ) + : 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: PlayerArtworkDragRegion( + child: 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: PlayerTransitionArtwork( + artworkKey: + artworkKeys.expanded, + child: ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: motionArtwork, + ), + ), + ) + : Semantics( + button: true, + label: context + .l10n + .nowPlayingTabPlayer, + child: GestureDetector( + behavior: + HitTestBehavior.opaque, + onTap: () => onPageChanged(0), + child: PlayerTransitionArtwork( + artworkKey: + artworkKeys.compact, + child: 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: artworkForeground, + builder: (context, _) => PlayerPlaybackControls( + key: artworkKeys.controls, + mediaId: mediaItem.id, + duration: mediaItem.duration ?? Duration.zero, + seekPreview: seekPreview, + colorScheme: foreground('controls'), + metadata: metadata, + compact: landscape, + transportTopPadding: 16 + transportShift, + ), + ); + + if (landscape) { + return MornyeLandscapePlayer( + page: page, + isPlaying: ref.watch(playbackPlayingProvider), + controlsHeldOpen: lyricsOptionsOpen, + onPageChanged: onPageChanged, + artwork: stage(artworkOnly: true), + header: _trackHeader(context, colorScheme, compact: true), + lyrics: lyricsBuilder(true), + lyricsOptions: showLyrics ? lyricsOptions : null, + queue: MornyePlayerQueue(colorScheme: colorScheme), + controls: controls(), + volume: const MornyeVolumeControl(), + ); + } + + final options = showLyrics ? lyricsOptions : null; + Widget content() => TweenAnimationBuilder( + tween: Tween(begin: 0, end: compactStage ? 1 : 0), + duration: motion, + curve: Curves.easeInOutCubic, + builder: (context, progress, controls) => CustomMultiChildLayout( + delegate: _PlayerHeaderLayout( + progress: progress, + compactHeight: compactHeaderHeight, + compactLeft: _mornyeContentInset + compactCoverSize + 12, + expandLyrics: showLyrics, + ), + children: [ + LayoutId( + id: _PlayerHeaderSlot.stage, + child: stage(progress: progress), + ), + LayoutId( + id: _PlayerHeaderSlot.header, + child: KeyedSubtree( + key: const ValueKey('player-track-header'), + child: AnimatedBuilder( + key: artworkKeys.header, + animation: artworkForeground, + builder: (context, _) => _trackHeader( + context, + foreground('header'), + compactProgress: progress, + ), + ), + ), + ), + LayoutId(id: _PlayerHeaderSlot.controls, child: controls!), + if (options != null) + LayoutId( + id: _PlayerHeaderSlot.lyricsOptions, + child: hideControls(options), + ), + ], + ), + child: hideControls( + Column( + mainAxisSize: MainAxisSize.min, + children: [ + controls(), + SizedBox(height: volumeGap - transportShift), + AnimatedBuilder( + animation: artworkForeground, + builder: (context, _) => MornyeVolumeControl( + key: artworkKeys.volume, + 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 = 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: body, + ); + } + + Widget _trackHeader( + BuildContext context, + ColorScheme colorScheme, { + bool compact = false, + double? compactProgress, + }) { + final progress = compactProgress ?? (compact ? 1.0 : 0.0); + final actionSize = compactProgress == null + ? (page == 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 + : () => onTrackNavigation(titleContext, mediaItem), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowMarquee( + resetKey: (mediaItem.id, mediaItem.title), + child: PlayerMetadataTitle( + mediaItem: mediaItem, + metadata: metadata, + 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: page != 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: () => onMoreActions( + buttonContext, + mediaItem, + mornyeMenuAnchor(buttonContext), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _mornyeLyricsViewport( + BuildContext context, + Widget child, { + required double visibleHeight, + }) => TweenAnimationBuilder( + tween: Tween(begin: 0, end: lyricsControlsHidden ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + builder: (context, reveal, child) => ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) { + final height = bounds.height.clamp(1.0, double.infinity); + final end = visibleHeight.clamp(0.0, height); + final start = (end - 24).clamp(0.0, end); + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [0, start / height, end / height, 1], + colors: [ + Colors.white, + Colors.white, + Colors.white.withValues(alpha: reveal), + Colors.white.withValues(alpha: reveal), + ], + ).createShader(bounds); + }, + child: child, + ), + child: child, + ); +} + +enum _PlayerHeaderSlot { stage, header, controls, lyricsOptions } + +/// 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, + required this.expandLyrics, + }); + + final double progress; + final double compactHeight; + final double compactLeft; + final bool expandLyrics; + + @override + void performLayout(Size size) { + final controls = layoutChild( + _PlayerHeaderSlot.controls, + BoxConstraints.tightFor(width: size.width), + ); + final contentHeight = (size.height - controls.height).clamp( + 0.0, + size.height, + ); + positionChild(_PlayerHeaderSlot.controls, Offset(0, contentHeight)); + 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 = (contentHeight - (header.height + 20) * (1 - progress)) + .clamp(0.0, contentHeight); + layoutChild( + _PlayerHeaderSlot.stage, + // Lyrics always extend behind controls. The minimum height marks their + // unobscured area for the paint mask; hiding controls never changes either + // constraint, scroll padding or the Scrollable's identity. + BoxConstraints( + minWidth: size.width, + maxWidth: size.width, + minHeight: stageHeight, + maxHeight: + stageHeight + (expandLyrics ? controls.height * progress : 0), + ), + ); + positionChild(_PlayerHeaderSlot.stage, Offset.zero); + positionChild( + _PlayerHeaderSlot.header, + Offset( + left, + (contentHeight - header.height - 8) * (1 - progress) + 8 * progress, + ), + ); + if (hasChild(_PlayerHeaderSlot.lyricsOptions)) { + final options = layoutChild( + _PlayerHeaderSlot.lyricsOptions, + BoxConstraints.loose(size), + ); + positionChild( + _PlayerHeaderSlot.lyricsOptions, + Offset(_mornyeContentInset, contentHeight - options.height - 8), + ); + } + } + + @override + bool shouldRelayout(_PlayerHeaderLayout oldDelegate) => + progress != oldDelegate.progress || + compactHeight != oldDelegate.compactHeight || + compactLeft != oldDelegate.compactLeft || + expandLyrics != oldDelegate.expandLyrics; +} diff --git a/lib/widgets/player_details_sheet.dart b/lib/widgets/player_details_sheet.dart new file mode 100644 index 00000000..06bcb775 --- /dev/null +++ b/lib/widgets/player_details_sheet.dart @@ -0,0 +1,177 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/isrc_utils.dart'; +import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +Future showPlayerDetailsSheet( + BuildContext context, + ColorScheme colorScheme, { + required PlayerMetadataController metadata, + required MediaItem? mediaItem, +}) { + final mornye = context.isMornye; + final fallback = metadata.detailsSnapshot(mediaItem); + final metadataFuture = metadata.readDetails(mediaItem); + return 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, + ), + ); + }, + ); + }, + ); +} + +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, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/player_gesture_regions.dart b/lib/widgets/player_gesture_regions.dart new file mode 100644 index 00000000..77c973c8 --- /dev/null +++ b/lib/widgets/player_gesture_regions.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/routes/now_playing_route.dart'; +import 'package:spotiflac_android/widgets/player_track_swipe.dart'; + +/// The footer opens the queue upwards and dismisses the route downwards. +class PlayerQueueSwipeRegion extends StatefulWidget { + const PlayerQueueSwipeRegion({ + super.key, + required this.onOpenQueue, + required this.child, + }); + + final VoidCallback onOpenQueue; + final Widget child; + + @override + State createState() => _PlayerQueueSwipeRegionState(); +} + +class _PlayerQueueSwipeRegionState extends State { + bool _forwarding = false; + double _total = 0; + + @override + Widget build(BuildContext context) { + final route = ModalRoute.of(context); + final playerRoute = route is NowPlayingRoute ? route : null; + final pageHeight = MediaQuery.sizeOf(context).height; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragStart: (_) { + _forwarding = false; + _total = 0; + }, + onVerticalDragUpdate: (details) { + if (_forwarding) { + playerRoute?.updateDrag(details, pageHeight); + return; + } + _total += details.primaryDelta ?? 0; + if (_total > 12 && playerRoute != null) { + playerRoute.startDrag(); + _forwarding = true; + } + }, + onVerticalDragEnd: (details) { + if (_forwarding) { + playerRoute?.endDrag(details, pageHeight); + } else if ((details.primaryVelocity ?? 0) < -300 || _total < -40) { + widget.onOpenQueue(); + } + }, + onVerticalDragCancel: () { + if (_forwarding) playerRoute?.cancelDrag(); + }, + child: widget.child, + ); + } +} + +/// Artwork forwards vertical drags inside scroll views to the player route. +class PlayerArtworkDragRegion extends StatelessWidget { + const PlayerArtworkDragRegion({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + 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, + ), + ); + } +} + +/// Hides the live cover while the route paints its captured dismissal cover. +class PlayerTransitionArtwork extends StatelessWidget { + const PlayerTransitionArtwork({ + super.key, + required this.artworkKey, + required this.child, + }); + + final GlobalKey artworkKey; + final Widget child; + + @override + Widget build(BuildContext context) { + final route = ModalRoute.of(context); + return AnimatedBuilder( + animation: route?.animation ?? kAlwaysCompleteAnimation, + child: child, + builder: (_, child) => Opacity( + key: artworkKey, + opacity: route is NowPlayingRoute && route.hasDismissArtwork ? 0 : 1, + child: child, + ), + ); + } +} diff --git a/lib/widgets/player_lyrics_controls.dart b/lib/widgets/player_lyrics_controls.dart new file mode 100644 index 00000000..e947aaef --- /dev/null +++ b/lib/widgets/player_lyrics_controls.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// Fades controls without changing the lyric viewport or active scroll gesture. +class PlayerLyricsControlsVisibility extends StatelessWidget { + const PlayerLyricsControlsVisibility({ + super.key, + required this.hidden, + required this.child, + }); + + final bool hidden; + final Widget child; + + @override + Widget build(BuildContext context) => IgnorePointer( + ignoring: hidden, + child: ExcludeSemantics( + excluding: hidden, + child: AnimatedOpacity( + opacity: hidden ? 0 : 1, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: child, + ), + ), + ); +} diff --git a/lib/widgets/player_lyrics_section.dart b/lib/widgets/player_lyrics_section.dart new file mode 100644 index 00000000..0c77ed7a --- /dev/null +++ b/lib/widgets/player_lyrics_section.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; + +class PlayerLyricsSection extends ConsumerWidget { + const PlayerLyricsSection({ + super.key, + required this.metadata, + required this.seekPreview, + required this.colorScheme, + required this.isActive, + this.options, + }); + + final PlayerMetadataController metadata; + final PlaybackSeekPreview seekPreview; + final ColorScheme colorScheme; + final bool isActive; + final Widget? options; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final visibility = ref.watch( + settingsProvider.select( + (settings) => + (settings.playerShowPronunciation, settings.playerShowTranslation), + ), + ); + return ListenableBuilder( + listenable: metadata, + builder: (context, _) => _buildLyricsSection(context, visibility), + ); + } + + Widget _buildLyricsSection(BuildContext context, (bool, bool) visibility) { + if (metadata.loading) { + return const Center(child: AppLoadingIndicator()); + } + if (metadata.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 (metadata.lyrics.synced) { + return Stack( + fit: StackFit.expand, + children: [ + SyncedLyricsView( + lyrics: metadata.lyrics, + seekPreview: seekPreview, + credits: _lyricsCredits(), + colorScheme: colorScheme, + isActive: isActive, + showPronunciation: visibility.$1, + showTranslation: visibility.$2, + ), + if (options != null) + Positioned( + left: context.isMornye ? mornyeLyricsContentInset : 24, + bottom: 8, + child: options!, + ), + ], + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + metadata.lyrics.plainText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + height: 1.6, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ?_lyricsCredits(), + ], + ), + ); + } + + LyricsCredits? _lyricsCredits() { + final (:writers, :provider) = metadata.credits; + if (writers == null && provider == null) return null; + return LyricsCredits(writers: writers, provider: provider); + } +} diff --git a/lib/widgets/player_metadata_title.dart b/lib/widgets/player_metadata_title.dart new file mode 100644 index 00000000..b4a1fbe8 --- /dev/null +++ b/lib/widgets/player_metadata_title.dart @@ -0,0 +1,45 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; + +/// File metadata can arrive after playback starts without rebuilding the layout. +class PlayerMetadataTitle extends StatelessWidget { + const PlayerMetadataTitle({ + super.key, + required this.mediaItem, + required this.metadata, + this.style, + this.textAlign, + this.maxLines, + this.overflow = TextOverflow.clip, + }); + + final MediaItem mediaItem; + final PlayerMetadataController metadata; + final TextStyle? style; + final TextAlign? textAlign; + final int? maxLines; + final TextOverflow overflow; + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: metadata, + builder: (context, _) { + final source = mediaItem.extras?['source']?.toString(); + final loaded = source == metadata.source ? metadata.metadata : null; + return ExplicitTrackTitle( + title: mediaItem.title, + explicit: + parseExplicitFlag(loaded?['explicit']) ?? + parseExplicitFlag(mediaItem.extras?['explicit']) ?? + false, + style: style, + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + ); + }, + ); +} diff --git a/lib/widgets/player_playback_controls.dart b/lib/widgets/player_playback_controls.dart new file mode 100644 index 00000000..993444cd --- /dev/null +++ b/lib/widgets/player_playback_controls.dart @@ -0,0 +1,369 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; +import 'package:spotiflac_android/widgets/automix_status_badge.dart'; +import 'package:spotiflac_android/widgets/expressive_icon_button.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/playback_seek_slider.dart'; + +const _mornyeTimelinePadding = 28.0; + +class PlayerPlaybackControls extends ConsumerWidget { + final String mediaId; + final Duration duration; + final PlaybackSeekPreview seekPreview; + final ColorScheme colorScheme; + final PlayerMetadataController metadata; + final bool compact; + final double transportTopPadding; + + const PlayerPlaybackControls({ + super.key, + required this.mediaId, + required this.duration, + required this.seekPreview, + required this.colorScheme, + required this.metadata, + this.compact = false, + this.transportTopPadding = 16, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(musicPlayerControllerProvider); + 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); + return Column( + children: [ + ValueListenableBuilder( + valueListenable: seekPreview, + builder: (context, preview, _) => Consumer( + builder: (context, ref, quality) { + 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, + playing: isPlaying && !isLoading, + ), + ), + if (!mornye) const SizedBox(height: 8), + 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: quality)), + 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, + ), + ], + ), + ), + ], + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _QualityBadge(metadata: metadata, colorScheme: colorScheme), + const AutoMixStatusBadge(), + ], + ), + ), + ), + SizedBox(height: compact ? 8 : transportTopPadding + (mornye ? 0 : 8)), + Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? (compact ? 16 : 32) : 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (!mornye) + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.shuffle, + colorScheme: colorScheme, + ), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.backward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + onPressed: controller.previous, + ) + else + ExpressiveIconButton( + iconSize: 44, + foregroundColor: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingPreviousTrack, + icon: const Icon(Icons.skip_previous), + onPressed: controller.previous, + ), + 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 + ExpressiveIconButton( + size: 68, + iconSize: 44, + selected: isPlaying, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + tooltip: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + icon: isLoading + ? AppLoadingIndicator(color: colorScheme.onSurfaceVariant) + : Icon(isPlaying ? Icons.pause : Icons.play_arrow), + onPressed: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + ), + if (mornye) + MornyePlaybackButton( + icon: CupertinoIcons.forward_fill, + color: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + onPressed: controller.next, + ) + else + ExpressiveIconButton( + iconSize: 44, + foregroundColor: colorScheme.onSurface, + tooltip: context.l10n.nowPlayingNextTrack, + icon: const Icon(Icons.skip_next), + onPressed: controller.next, + ), + if (!mornye) + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.repeat, + colorScheme: colorScheme, + ), + ], + ), + ), + ], + ); + } +} + +enum PlayerPlaybackMode { autoplay, repeat, shuffle } + +/// Queue and transport buttons share behavior while keeping independent watches. +class PlayerPlaybackModeButton extends ConsumerWidget { + const PlayerPlaybackModeButton({ + super.key, + required this.mode, + required this.colorScheme, + this.secondary = false, + }); + + final PlayerPlaybackMode mode; + final ColorScheme colorScheme; + final bool secondary; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(musicPlayerControllerProvider); + late final bool selected; + late final IconData icon; + late final String tooltip; + late final VoidCallback onPressed; + switch (mode) { + case PlayerPlaybackMode.autoplay: + selected = ref.watch( + settingsProvider.select((settings) => settings.autoplay), + ); + icon = Icons.all_inclusive; + tooltip = selected ? context.l10n.autoplayOn : context.l10n.autoplayOff; + onPressed = () => + ref.read(settingsProvider.notifier).setAutoplay(!selected); + case PlayerPlaybackMode.shuffle: + selected = ref.watch( + playbackStateProvider.select( + (state) => state.value?.shuffleMode == AudioServiceShuffleMode.all, + ), + ); + icon = Icons.shuffle; + tooltip = selected + ? context.l10n.nowPlayingShuffleOn + : context.l10n.nowPlayingPlayInOrder; + onPressed = () => controller.setShuffle(!selected); + case PlayerPlaybackMode.repeat: + final repeatMode = ref.watch( + playbackStateProvider.select( + (state) => state.value?.repeatMode ?? AudioServiceRepeatMode.none, + ), + ); + selected = repeatMode != AudioServiceRepeatMode.none; + icon = repeatMode == AudioServiceRepeatMode.one + ? Icons.repeat_one + : Icons.repeat; + tooltip = switch (repeatMode) { + AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, + AudioServiceRepeatMode.none => context.l10n.nowPlayingRepeatOff, + _ => context.l10n.nowPlayingRepeatAll, + }; + onPressed = () => controller.setRepeatMode(switch (repeatMode) { + AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, + AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }); + } + return ExpressiveIconButton( + iconSize: 24, + selected: selected, + tooltip: tooltip, + icon: Icon(icon), + foregroundColor: selected + ? (secondary ? colorScheme.onSecondaryContainer : colorScheme.primary) + : colorScheme.onSurfaceVariant, + backgroundColor: selected + ? (secondary + ? colorScheme.secondaryContainer + : colorScheme.primaryContainer) + : null, + onPressed: onPressed, + ); + } +} + +class _QualityBadge extends StatelessWidget { + final PlayerMetadataController metadata; + final ColorScheme colorScheme; + + const _QualityBadge({required this.metadata, required this.colorScheme}); + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: metadata, + builder: (context, _) => _buildBadge(context), + ); + + Widget _buildBadge(BuildContext context) { + final text = metadata.qualityLabel; + if (text == null || text.isEmpty) return const SizedBox.shrink(); + final mornye = context.isMornye; + return Container( + padding: EdgeInsets.symmetric( + horizontal: mornye ? 8 : 10, + vertical: mornye ? 2 : 5, + ), + decoration: mornye + ? null + : BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.graphic_eq, + size: mornye ? 11 : 14, + color: colorScheme.onSurfaceVariant, + ), + SizedBox(width: mornye ? 5 : 6), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: mornye ? 10.5 : 12, + color: colorScheme.onSurfaceVariant, + letterSpacing: 0.2, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/player_queue_sheet.dart b/lib/widgets/player_queue_sheet.dart new file mode 100644 index 00000000..eb83017a --- /dev/null +++ b/lib/widgets/player_queue_sheet.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/widgets/expressive_button.dart'; +import 'package:spotiflac_android/widgets/player_playback_controls.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; + +Future showPlayerQueueSheet( + BuildContext context, + ColorScheme colorScheme, +) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: colorScheme.surfaceContainerHigh, + builder: (context) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.6, + maxChildSize: 0.9, + minChildSize: 0.4, + builder: (context, scrollController) => PlayerQueueSheet( + colorScheme: colorScheme, + scrollController: scrollController, + ), + ), +); + +class PlayerQueueSheet extends ConsumerWidget { + const PlayerQueueSheet({ + super.key, + required this.colorScheme, + required this.scrollController, + }); + + final ColorScheme colorScheme; + final ScrollController scrollController; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final queue = ref.watch(playQueueProvider).value ?? const []; + final current = ref.watch(currentMediaItemProvider).value; + final controller = ref.read(musicPlayerControllerProvider); + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + context.l10n.nowPlayingUpNext, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ), + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.autoplay, + colorScheme: colorScheme, + secondary: true, + ), + const SizedBox(width: 4), + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.repeat, + colorScheme: colorScheme, + secondary: true, + ), + const SizedBox(width: 4), + PlayerPlaybackModeButton( + mode: PlayerPlaybackMode.shuffle, + colorScheme: colorScheme, + secondary: true, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: SizedBox( + width: double.infinity, + child: ExpressiveButton( + tonal: true, + onPressed: () => _shuffleLibrary(context, controller), + icon: const Icon(Icons.shuffle, size: 18), + child: 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( + selected: isCurrent, + selectedTileColor: colorScheme.secondaryContainer + .withValues(alpha: 0.55), + contentPadding: const EdgeInsets.only(left: 16, right: 4), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox.square( + dimension: 44, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: 132, + iconSize: 22, + ), + ), + ), + 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), + ), + ); + }, + ), + ), + ], + ); + } + + Future _shuffleLibrary( + BuildContext context, + MusicPlayerController controller, + ) async { + try { + if (!await controller.shuffleLibrary()) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)), + ); + return; + } + if (context.mounted) Navigator.of(context).maybePop(); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.nowPlayingShuffleLibraryFailed( + context.friendlyError(e), + ), + ), + ), + ); + } + } +} diff --git a/test/app_orientation_test.dart b/test/app_orientation_test.dart index 0b4cde91..abf5974e 100644 --- a/test/app_orientation_test.dart +++ b/test/app_orientation_test.dart @@ -5,6 +5,8 @@ import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/services/app_orientation.dart'; class _PlayerRoute extends NowPlayingRoute { + _PlayerRoute() : super(child: const SizedBox()); + @override Widget buildPage( BuildContext context, diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index ae29809f..37f650ec 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -2338,8 +2338,9 @@ void main() { body: Align( alignment: Alignment.bottomLeft, child: TextButton( - onPressed: () => - Navigator.of(context).push(NowPlayingRoute()), + onPressed: () => Navigator.of( + context, + ).push(NowPlayingRoute(child: const NowPlayingScreen())), child: const Hero( tag: kNowPlayingArtworkHeroTag, child: SizedBox.square(dimension: 38, child: Text('Open')), diff --git a/test/now_playing_route_test.dart b/test/now_playing_route_test.dart index a350c46a..529fbdd6 100644 --- a/test/now_playing_route_test.dart +++ b/test/now_playing_route_test.dart @@ -12,7 +12,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; const _sheet = Key('player-sheet'); class _TestPlayerRoute extends NowPlayingRoute { - _TestPlayerRoute({super.miniPlayerGeometry}); + _TestPlayerRoute({super.miniPlayerGeometry}) : super(child: const SizedBox()); @override Widget buildPage( @@ -170,7 +170,7 @@ void main() { ), ), ); - final route = NowPlayingRoute(); + final route = NowPlayingRoute(child: const NowPlayingScreen()); navigator.currentState!.push(route); await tester.pumpAndSettle(); diff --git a/test/player_widget_ownership_test.dart b/test/player_widget_ownership_test.dart new file mode 100644 index 00000000..4aeec8e2 --- /dev/null +++ b/test/player_widget_ownership_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/routes/now_playing_route.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; +import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/material_player_layout.dart'; +import 'package:spotiflac_android/widgets/player_details_sheet.dart'; +import 'package:spotiflac_android/widgets/player_gesture_regions.dart'; + +MediaItem _item(String id, {String? source}) => MediaItem( + id: id, + title: id, + artist: 'Artist', + album: 'Album', + duration: const Duration(minutes: 3), + extras: {'source': source ?? '/music/$id.flac'}, +); + +class _FooterRoute extends NowPlayingRoute { + _FooterRoute(this.onOpenQueue) : super(child: const SizedBox()); + + final VoidCallback onOpenQueue; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => PlayerQueueSwipeRegion( + onOpenQueue: onOpenQueue, + child: const ColoredBox( + key: ValueKey('gesture-player'), + color: Colors.black, + ), + ); +} + +void main() { + testWidgets('metadata and mode changes leave the player visual intact', ( + tester, + ) async { + final read = Completer>(); + final metadata = PlayerMetadataController(readMetadata: (_) => read.future); + final seekPreview = PlaybackSeekPreview(); + final playback = StreamController.broadcast(); + addTearDown(metadata.dispose); + addTearDown(seekPreview.dispose); + addTearDown(playback.close); + final item = _item('First'); + final pending = metadata.load(item); + var visualBuilds = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + playbackStateProvider.overrideWith((ref) => playback.stream), + playerCollectionTrackProvider.overrideWith( + (ref, item) async => Track( + id: item.id, + name: item.title, + artistName: item.artist ?? '', + albumName: item.album ?? '', + duration: 180, + source: 'local', + ), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: MaterialPlayerLayout( + mediaItem: item, + metadata: metadata, + seekPreview: seekPreview, + colorScheme: Theme.of(context).colorScheme, + showLyrics: true, + lyricsBuilder: (_) { + visualBuilds++; + return const SizedBox.expand(); + }, + onOpenQueue: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final beforeMetadata = visualBuilds; + read.complete({ + 'explicit': true, + 'format': 'FLAC', + 'sample_rate': 48000, + 'bit_depth': 24, + }); + await pending; + await tester.pumpAndSettle(); + expect(visualBuilds, beforeMetadata); + expect( + tester + .widget(find.byType(ExplicitTrackTitle)) + .explicit, + isTrue, + ); + const quality = 'FLAC · 24-bit · 48 kHz'; + final qualityWidget = tester.widget(find.text(quality)); + + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 45), + shuffleMode: AudioServiceShuffleMode.all, + repeatMode: AudioServiceRepeatMode.one, + ), + ); + await tester.pump(); + await tester.pump(); + expect(visualBuilds, beforeMetadata); + expect(find.text('0:45'), findsOneWidget); + expect( + identical(tester.widget(find.text(quality)), qualityWidget), + isTrue, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('details retain their captured item when playback changes', ( + tester, + ) async { + final read = Completer>(); + final metadata = PlayerMetadataController( + readMetadata: (path) => path.startsWith('content://') + ? read.future + : Future.value({'title': 'Second tags'}), + ); + addTearDown(metadata.dispose); + final first = _item('First', source: 'content://library/first.flac'); + await metadata.load(first); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showPlayerDetailsSheet( + context, + Theme.of(context).colorScheme, + metadata: metadata, + mediaItem: first, + ), + child: const Text('Details'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Details')); + await tester.pumpAndSettle(); + expect(find.text('First'), findsOneWidget); + await metadata.load(_item('Second')); + read.complete({'title': 'First tags'}); + await tester.pumpAndSettle(); + expect(find.text('First tags'), findsOneWidget); + expect(find.text('Second tags'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('footer opens the queue upwards and dismisses downwards', ( + tester, + ) async { + final navigator = GlobalKey(); + var queueOpens = 0; + await tester.pumpWidget( + MaterialApp(navigatorKey: navigator, home: const SizedBox()), + ); + navigator.currentState!.push(_FooterRoute(() => queueOpens++)); + await tester.pumpAndSettle(); + final footer = find.byKey(const ValueKey('gesture-player')); + await tester.fling(footer, const Offset(0, -80), 600); + await tester.pumpAndSettle(); + expect(queueOpens, 1); + expect(footer, findsOneWidget); + await tester.fling(footer, const Offset(0, 240), 1000); + await tester.pumpAndSettle(); + expect(queueOpens, 1); + expect(footer, findsNothing); + expect(tester.takeException(), isNull); + }); +}