diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart index d02edfb3..5eba0458 100644 --- a/lib/screens/music/now_playing_screen.dart +++ b/lib/screens/music/now_playing_screen.dart @@ -24,6 +24,7 @@ import '../../theme/mono_motion.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; +import '../../utils/desktop_window_padding.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; import '../../utils/platform_detector.dart'; @@ -58,13 +59,31 @@ class NowPlayingScreen extends StatefulWidget { State createState() => _NowPlayingScreenState(); } -class _NowPlayingScreenState extends State with ContextMenuTapMixin { +class _NowPlayingScreenState extends State + with ContextMenuTapMixin, SingleTickerProviderStateMixin { MusicPlaybackService? _service; StreamSubscription? _errorsSub; bool _showLyrics = false; final Map> _lyricsCache = {}; + /// Key for getting a context below this screen's own OverlaySheetHost — + /// the State's context sits ABOVE it, so sheet calls made with `context` + /// would miss the host and fall back to a hostless modal sheet. + final GlobalKey _overlayChildKey = GlobalKey(); + + BuildContext get _sheetContext => _overlayChildKey.currentContext ?? context; + + /// Swipe-down-to-dismiss (mobile portrait). The offset feeds a + /// ValueListenableBuilder-wrapped Transform so drag frames never rebuild + /// the screen. + final ValueNotifier _dismissDrag = ValueNotifier(0); + late final AnimationController _dismissSettle = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(_onDismissSettleTick); + double _dismissSettleFrom = 0; + final FocusNode _seekFocusNode = FocusNode(debugLabel: 'now_playing_seek'); final FocusNode _overflowFocusNode = FocusNode(debugLabel: 'now_playing_overflow'); final FocusNode _playPauseFocusNode = FocusNode(debugLabel: 'now_playing_play_pause'); @@ -92,6 +111,8 @@ class _NowPlayingScreenState extends State with ContextMenuTap @override void dispose() { _errorsSub?.cancel(); + _dismissSettle.dispose(); + _dismissDrag.dispose(); _seekFocusNode.dispose(); _overflowFocusNode.dispose(); _playPauseFocusNode.dispose(); @@ -119,6 +140,39 @@ class _NowPlayingScreenState extends State with ContextMenuTap void _pop() => Navigator.pop(context); + // Swipe-down anywhere above the transport controls closes the screen — + // the standard music-player gesture. The content follows the finger; + // releasing past the threshold pops (the route's reverse slide+fade + // continues the motion from where the finger left off), otherwise the + // content settles back. + + void _onDismissSettleTick() { + _dismissDrag.value = _dismissSettleFrom * (1 - Curves.easeOutCubic.transform(_dismissSettle.value)); + } + + void _onDismissDragStart(DragStartDetails details) => _dismissSettle.stop(); + + void _onDismissDragUpdate(DragUpdateDetails details) { + _dismissDrag.value = math.max(0, _dismissDrag.value + details.delta.dy); + } + + void _onDismissDragEnd(DragEndDetails details) { + final offset = _dismissDrag.value; + if (offset <= 0) return; + // Same thresholds as the overlay sheet system's drag-to-dismiss. + if (offset > MediaQuery.sizeOf(context).height * 0.25 || (details.primaryVelocity ?? 0) > 500) { + _pop(); + } else { + _onDismissDragCancel(); + } + } + + void _onDismissDragCancel() { + if (_dismissDrag.value <= 0) return; + _dismissSettleFrom = _dismissDrag.value; + _dismissSettle.forward(from: 0); + } + /// Artist line tap — the track's grandparent is the artist. Mirrors the /// album screen's artist link (fetch, then navigate; soft-fail). Future _openArtist(MediaItem track) async { @@ -139,7 +193,7 @@ class _NowPlayingScreenState extends State with ContextMenuTap final service = context.read(); final timed = service.sleepTimerActive && !service.sleepTimerEndOfTrack; final selected = await OverlaySheetController.showAdaptive( - context, + _sheetContext, showDragHandle: true, builder: (context) => AppMenuSheet( title: t.music.sleepTimer, @@ -231,7 +285,15 @@ class _NowPlayingScreenState extends State with ContextMenuTap // the sheet, otherwise the route pops natively — audio continues). return OverlaySheetHost( canPop: true, - child: Scaffold(backgroundColor: tk.bg, body: content), + child: Scaffold( + key: _overlayChildKey, + backgroundColor: tk.bg, + body: ValueListenableBuilder( + valueListenable: _dismissDrag, + builder: (context, offset, child) => Transform.translate(offset: Offset(0, offset), child: child), + child: content, + ), + ), ); } @@ -240,7 +302,7 @@ class _NowPlayingScreenState extends State with ContextMenuTap // ------------------------------------------------------------------- Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) { - return Column( + Widget upper = Column( children: [ _buildTopBar(track, service.playContext?.title), Expanded( @@ -250,6 +312,24 @@ class _NowPlayingScreenState extends State with ContextMenuTap ), ), Padding(padding: const EdgeInsets.fromLTRB(32, 8, 32, 0), child: _buildTrackInfo(track, centered: true)), + ], + ); + // Touch only — desktop keeps mouse drags for text selection and the + // lyrics pane. A drag starting on the lyrics scrollable still scrolls + // (the descendant recognizer wins the arena). + if (!PlatformDetector.isDesktopOS()) { + upper = GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _onDismissDragStart, + onVerticalDragUpdate: _onDismissDragUpdate, + onVerticalDragEnd: _onDismissDragEnd, + onVerticalDragCancel: _onDismissDragCancel, + child: upper, + ); + } + return Column( + children: [ + Expanded(child: upper), Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar()), _buildTransportRow(service), _buildUtilityRow(showQueueButton: true), @@ -279,8 +359,7 @@ class _NowPlayingScreenState extends State with ContextMenuTap _buildTrackInfo(track, centered: false), const SizedBox(height: 8), _buildSeekBar(), - _buildTransportRow(service), - _buildUtilityRow(showQueueButton: false), + _buildWideControlBand(service), const SizedBox(height: 12), // Inline queue panel — same widget the queue sheet uses. Expanded( @@ -373,32 +452,120 @@ class _NowPlayingScreenState extends State with ContextMenuTap /// screen's element asserts; the screen already watches the service. Widget _buildTopBar(MediaItem track, String? playContextTitle) { final tk = tokens(context); + // macOS pins the traffic lights at y=21 (16pt buttons → center 29, see + // WindowUtilsPlugin.customButtonPositions); a fixed 58px row centers the + // close button on that line regardless of the platform visual density + // shrinking the IconButton box. + final isMacOS = Theme.of(context).platform == TargetPlatform.macOS; return Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), - child: Row( - children: [ - IconButton( - icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text), - tooltip: t.common.close, - onPressed: _pop, - ), - Expanded( - child: playContextTitle == null || playContextTitle.isEmpty - ? const SizedBox.shrink() - : Text( - t.music.playingFrom(title: playContextTitle), - textAlign: TextAlign.center, - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 13, color: tk.textMuted), - ), - ), - _buildOverflowButton(track), - ], + padding: EdgeInsets.fromLTRB(8, isMacOS ? 0 : 4, 8, 0), + child: SizedBox( + height: isMacOS ? 58 : null, + child: Row( + children: [ + // Inset past the macOS traffic lights — this screen is a fullscreen + // route, so the close button would otherwise sit underneath them. + DesktopAppBarHelper.buildAdjustedLeading( + IconButton( + icon: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, color: tk.text), + tooltip: t.common.close, + onPressed: _pop, + ), + context: context, + )!, + Expanded( + child: playContextTitle == null || playContextTitle.isEmpty + ? const SizedBox.shrink() + : Text( + t.music.playingFrom(title: playContextTitle), + textAlign: TextAlign.center, + maxLines: 1, + overflow: .ellipsis, + style: TextStyle(fontSize: 13, color: tk.textMuted), + ), + ), + _buildOverflowButton(track), + ], + ), ), ); } + /// Wide-layout control band: the transport row stays exactly centered + /// (equal-width flanks), with lyrics + a desktop volume slider as a + /// right-aligned cluster that scales down instead of overflowing when the + /// pane is narrow. + Widget _buildWideControlBand(MusicPlaybackService service) { + final tk = tokens(context); + final colorScheme = Theme.of(context).colorScheme; + + final cluster = Row( + mainAxisSize: .min, + children: [ + IconButton( + icon: AppIcon( + Symbols.lyrics_rounded, + fill: 1, + size: 22, + color: _showLyrics ? colorScheme.primary : tk.textMuted, + ), + tooltip: t.music.lyrics, + onPressed: _toggleLyrics, + ), + if (PlatformDetector.isDesktop(context)) ...[const SizedBox(width: 4), _buildVolumeCluster(service)], + ], + ); + + return Row( + children: [ + const Expanded(child: SizedBox()), + _buildTransportRow(service), + Expanded( + child: Align( + alignment: .centerRight, + child: FittedBox(fit: BoxFit.scaleDown, child: cluster), + ), + ), + ], + ); + } + + /// Desktop volume control (0–100), persisted by the service across + /// sessions. Mono styling matches the seek bar: text-colored active track + /// on outline. + Widget _buildVolumeCluster(MusicPlaybackService service) { + final tk = tokens(context); + final icon = service.volume <= 0 + ? Symbols.volume_off_rounded + : service.volume < 50 + ? Symbols.volume_down_rounded + : Symbols.volume_up_rounded; + return Row( + mainAxisSize: .min, + children: [ + AppIcon(icon, fill: 1, size: 20, color: tk.textMuted), + SizedBox( + width: 140, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 3, + activeTrackColor: tk.text, + inactiveTrackColor: tk.outline, + thumbColor: tk.text, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Slider( + value: service.volume.clamp(0.0, 100.0), + max: 100, + onChanged: (value) => unawaited(service.setVolume(value)), + ), + ), + ), + ], + ); + } + /// ⋮ — the current track's standard context menu plus the Sleep timer /// entry. On TV ([focusable]) it joins the d-pad chain above the seek bar. Widget _buildOverflowButton(MediaItem track, {bool focusable = false}) { @@ -639,13 +806,13 @@ class _NowPlayingScreenState extends State with ContextMenuTap if (showQueueButton) FocusableAction( debugLabel: 'np_queue', - onPressed: () => unawaited(showQueueSheet(context)), + onPressed: () => unawaited(showQueueSheet(_sheetContext)), builder: (context, state) => _transportIcon( state, icon: Symbols.queue_music_rounded, active: false, tooltip: t.music.queue, - onPressed: () => unawaited(showQueueSheet(context)), + onPressed: () => unawaited(showQueueSheet(_sheetContext)), size: 22, ), ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index cc5a03af..1b7b2eee 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -580,8 +580,12 @@ class MediaContextMenuState extends State { _openedFromKeyboard = false; if (useBottomSheet) { + // Present from the menu's own context: it sits at the trigger widget, + // below any screen-level OverlaySheetHost, while callers often pass a + // screen context from ABOVE its host (which would skip the host and + // fall back to a hostless modal sheet). selected = await OverlaySheetController.showAdaptive( - context, + this.context, showDragHandle: true, builder: (context) => AppMenuSheet( title: _itemDisplayTitle(), @@ -966,10 +970,11 @@ class MediaContextMenuState extends State { loadingShown = false; } - if (fileInfo != null && context.mounted) { - // Show file info bottom sheet + if (fileInfo != null && context.mounted && mounted) { + // Show file info bottom sheet, presented from the menu's own context + // so a screen-level OverlaySheetHost is found (see _showContextMenu). await OverlaySheetController.showAdaptive( - context, + this.context, isScrollControlled: true, builder: (context) => FileInfoBottomSheet(fileInfo: fileInfo, title: item.displayTitle), ); @@ -1308,8 +1313,11 @@ class MediaContextMenuState extends State { } Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { + if (!mounted) return; + // Presented from the menu's own context so a screen-level + // OverlaySheetHost is found (see _showContextMenu). await OverlaySheetController.showAdaptive( - context, + this.context, showDragHandle: true, builder: (context) => RatingBottomSheet( item: item, diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart index b76ae0b0..cd7a59ed 100644 --- a/lib/widgets/music/mini_player.dart +++ b/lib/widgets/music/mini_player.dart @@ -19,6 +19,7 @@ import '../../utils/video_player_navigation.dart'; import '../app_icon.dart'; import '../media_context_menu.dart'; import '../optimized_media_image.dart'; +import '../overlay_sheet.dart'; /// Suppresses the mini-player while the top PAGE route of the profile /// navigator is a full-screen playback surface (the video player or the @@ -177,12 +178,18 @@ class _MusicMiniPlayerOverlayState extends State { if (_lastTrack == null) return const SizedBox.shrink(); final suppress = context.read()?.suppress ?? _noSuppression; - return ValueListenableBuilder( - valueListenable: suppress, - builder: (context, suppressed, _) { - final visible = track != null && !suppressed && !_dismissed; + final openSheets = OverlaySheetController.openSheetCount; + return ListenableBuilder( + listenable: Listenable.merge([suppress, openSheets]), + builder: (context, _) { + final hasSession = track != null && !suppress.value && !_dismissed; + // Sheets render inside the routes' subtrees, BELOW this overlay — + // hide the card while any sheet is up so it never floats over one. + final visible = hasSession && openSheets.value == 0; final useSideNav = PlatformDetector.shouldUseSideNavigation(context); - _reportOverlayHeight(visible ? _cardHeight + (useSideNav ? 32 : 24) : 0); + // Report off the session (not sheet) state so the underlying + // screen's scroll padding doesn't jump while a sheet is open. + _reportOverlayHeight(hasSession ? _cardHeight + (useSideNav ? 32 : 24) : 0); final Widget child = visible ? _MiniPlayerCard( @@ -270,6 +277,10 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix height: _MusicMiniPlayerOverlayState._cardHeight, child: Stack( children: [ + // Played fraction tints the card background itself — the card + // fills up as the track progresses (clipped by the Material's + // rounded corners above). + const Positioned.fill(child: _MiniPlayerProgress()), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( @@ -343,7 +354,6 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix ], ), ), - const Positioned(left: 0, right: 0, bottom: 0, height: 2, child: _MiniPlayerProgress()), ], ), ), @@ -376,8 +386,9 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix } } -/// Isolated progress leaf — positionStream ticks rebuild only this 2px line, -/// never the card above it. +/// Isolated progress leaf — positionStream ticks rebuild only this +/// background layer, never the card content above it. The played fraction +/// renders as a subtle full-height tint that fills the card left-to-right. class _MiniPlayerProgress extends StatelessWidget { const _MiniPlayerProgress(); @@ -396,7 +407,7 @@ class _MiniPlayerProgress extends StatelessWidget { child: FractionallySizedBox( widthFactor: fraction, heightFactor: 1, - child: ColoredBox(color: tk.text.withValues(alpha: 0.9)), + child: ColoredBox(color: tk.text.withValues(alpha: 0.08)), ), ); }, diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index d8e7380b..aa669509 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -46,6 +46,12 @@ class OverlaySheetController { return context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>()?.controller; } + /// Number of sheets currently open across all hosts (and [showAdaptive] + /// modal fallbacks). Sheets render inside their host's subtree, so chrome + /// mounted above the navigator (the music mini-player) can never sit under + /// them — such chrome listens here and hides itself while this is nonzero. + static final ValueNotifier openSheetCount = ValueNotifier(0); + /// Whether a sheet is currently showing (including while animating closed). bool get isOpen => _state._isOpen; @@ -108,7 +114,7 @@ class OverlaySheetController { FocusNode? initialFocusNode, Alignment alignment = Alignment.bottomCenter, bool showDragHandle = false, - }) { + }) async { final controller = maybeOf(context); if (controller != null) { return controller.show( @@ -130,14 +136,22 @@ class OverlaySheetController { final isDesktop = size.width > 600; return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); }(); - return showModalBottomSheet( - context: context, - builder: builder, - constraints: effectiveConstraints, - backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, - barrierColor: Colors.black54, - isScrollControlled: isScrollControlled, - ); + openSheetCount.value++; + try { + return await showModalBottomSheet( + context: context, + // The host path insets its sheet by the bottom safe area; mirror that + // here so the last row clears the home indicator / gesture nav bar. + builder: (context) => SafeArea(top: false, child: builder(context)), + constraints: effectiveConstraints, + backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, + barrierColor: Colors.black54, + isScrollControlled: isScrollControlled, + showDragHandle: showDragHandle, + ); + } finally { + openSheetCount.value--; + } } /// Push a sub-page using the overlay system if available, otherwise fall @@ -146,12 +160,20 @@ class OverlaySheetController { BuildContext context, { required WidgetBuilder builder, FocusNode? initialFocusNode, - }) { + }) async { final controller = maybeOf(context); if (controller != null) { return controller.push(builder: builder, initialFocusNode: initialFocusNode); } - return showModalBottomSheet(context: context, builder: builder); + openSheetCount.value++; + try { + return await showModalBottomSheet( + context: context, + builder: (context) => SafeArea(top: false, child: builder(context)), + ); + } finally { + openSheetCount.value--; + } } /// Close the sheet entirely. Uses overlay controller if available, @@ -270,6 +292,9 @@ class _OverlaySheetHostState extends State with SingleTickerPr entry.completer.complete(null); } } + // A host torn down mid-sheet (or mid-close animation) never reaches the + // close completion below — release its slot in the global count here. + if (_isOpen) OverlaySheetController.openSheetCount.value--; _sheetFocusScopeNode.dispose(); _slideCurve.dispose(); _animationController.dispose(); @@ -314,7 +339,10 @@ class _OverlaySheetHostState extends State with SingleTickerPr _dragOffset = 0; _isDragging = false; }); - if (!wasOpen) widget.onOpenChanged?.call(true); + if (!wasOpen) { + widget.onOpenChanged?.call(true); + OverlaySheetController.openSheetCount.value++; + } BackKeyUpSuppressor.clearSuppression(); _animationController.forward(from: 0); @@ -376,6 +404,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _sheetHorizontalAnchor = null; }); widget.onOpenChanged?.call(false); + OverlaySheetController.openSheetCount.value--; }); }