fix(music): sheet safe-area, swipe-to-dismiss, hide mini-player under sheets

This commit is contained in:
edde746
2026-07-06 10:51:19 +02:00
parent a4d40486c1
commit fcdef343eb
4 changed files with 270 additions and 55 deletions
+196 -29
View File
@@ -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<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends State<NowPlayingScreen> with ContextMenuTapMixin<NowPlayingScreen> {
class _NowPlayingScreenState extends State<NowPlayingScreen>
with ContextMenuTapMixin<NowPlayingScreen>, SingleTickerProviderStateMixin {
MusicPlaybackService? _service;
StreamSubscription<Object>? _errorsSub;
bool _showLyrics = false;
final Map<String, Future<Lyrics?>> _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<double> _dismissDrag = ValueNotifier<double>(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<NowPlayingScreen> 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<NowPlayingScreen> 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<void> _openArtist(MediaItem track) async {
@@ -139,7 +193,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> with ContextMenuTap
final service = context.read<MusicPlaybackService>();
final timed = service.sleepTimerActive && !service.sleepTimerEndOfTrack;
final selected = await OverlaySheetController.showAdaptive<String>(
context,
_sheetContext,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(
title: t.music.sleepTimer,
@@ -231,7 +285,15 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> 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<double>(
valueListenable: _dismissDrag,
builder: (context, offset, child) => Transform.translate(offset: Offset(0, offset), child: child),
child: content,
),
),
);
}
@@ -240,7 +302,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> 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<NowPlayingScreen> 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<NowPlayingScreen> 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<NowPlayingScreen> 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 (0100), 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<NowPlayingScreen> 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,
),
),
+13 -5
View File
@@ -580,8 +580,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
_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<String>(
context,
this.context,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(
title: _itemDisplayTitle(),
@@ -966,10 +970,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
}
Future<void> _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,
+20 -9
View File
@@ -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<MusicMiniPlayerOverlay> {
if (_lastTrack == null) return const SizedBox.shrink();
final suppress = context.read<MusicUiRouteObserver?>()?.suppress ?? _noSuppression;
return ValueListenableBuilder<bool>(
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)),
),
);
},
+41 -12
View File
@@ -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<int> openSheetCount = ValueNotifier<int>(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<T>(
@@ -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<T>(
context: context,
builder: builder,
constraints: effectiveConstraints,
backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface,
barrierColor: Colors.black54,
isScrollControlled: isScrollControlled,
);
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
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<T>(builder: builder, initialFocusNode: initialFocusNode);
}
return showModalBottomSheet<T>(context: context, builder: builder);
openSheetCount.value++;
try {
return await showModalBottomSheet<T>(
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<OverlaySheetHost> 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<OverlaySheetHost> 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<OverlaySheetHost> with SingleTickerPr
_sheetHorizontalAnchor = null;
});
widget.onOpenChanged?.call(false);
OverlaySheetController.openSheetCount.value--;
});
}