Persistent mini-player over the profile navigator (route-aware suppression, bottom-bar inset), slide-up now-playing screen with mobile/desktop/TV layouts (blurred-art background, pause shape morphs, dpad seek chain), queue sheet with reorder/remove/jump, synced lyrics view, TV rail now-playing item, sleep timer, and audio playlist playback. Adds download affordances on music surfaces.
406 lines
15 KiB
Dart
406 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../media/ids.dart';
|
|
import '../../media/media_item.dart';
|
|
import '../../mixins/context_menu_tap_mixin.dart';
|
|
import '../../services/music/music_playback_service.dart';
|
|
import '../../theme/mono_motion.dart';
|
|
import '../../theme/mono_tokens.dart';
|
|
import '../../utils/media_image_helper.dart';
|
|
import '../../utils/music_navigation.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../utils/provider_extensions.dart';
|
|
import '../../utils/video_player_navigation.dart';
|
|
import '../app_icon.dart';
|
|
import '../media_context_menu.dart';
|
|
import '../optimized_media_image.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
|
|
/// now-playing screen). Popup routes (dialogs, menus) riding on top are
|
|
/// skipped when resolving the "top" — a menu over now-playing must not
|
|
/// resurface the mini-player, while a detail screen pushed above it must.
|
|
///
|
|
/// Registered on the profile navigator's `observers` list by
|
|
/// `profile_session_screen.dart` and provided to [MusicMiniPlayerOverlay].
|
|
class MusicUiRouteObserver extends NavigatorObserver {
|
|
/// True while the mini-player should stay hidden for route reasons.
|
|
final ValueNotifier<bool> suppress = ValueNotifier<bool>(false);
|
|
|
|
final List<Route<dynamic>> _stack = [];
|
|
|
|
static bool _isSuppressingRoute(Route<dynamic> route) {
|
|
final name = route.settings.name;
|
|
return name == kVideoPlayerRouteName || name == kNowPlayingRouteName;
|
|
}
|
|
|
|
void _recompute() {
|
|
var suppressing = false;
|
|
for (var i = _stack.length - 1; i >= 0; i--) {
|
|
final route = _stack[i];
|
|
if (route is PopupRoute) continue;
|
|
suppressing = _isSuppressingRoute(route);
|
|
break;
|
|
}
|
|
suppress.value = suppressing;
|
|
}
|
|
|
|
@override
|
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
_stack.add(route);
|
|
_recompute();
|
|
}
|
|
|
|
@override
|
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
_stack.remove(route);
|
|
_recompute();
|
|
}
|
|
|
|
@override
|
|
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
|
_stack.remove(route);
|
|
_recompute();
|
|
}
|
|
|
|
@override
|
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
|
final index = oldRoute == null ? -1 : _stack.indexOf(oldRoute);
|
|
if (index >= 0) {
|
|
if (newRoute != null) {
|
|
_stack[index] = newRoute;
|
|
} else {
|
|
_stack.removeAt(index);
|
|
}
|
|
} else if (newRoute != null) {
|
|
_stack.add(newRoute);
|
|
}
|
|
_recompute();
|
|
}
|
|
}
|
|
|
|
/// Coordinates the mini-player's vertical placement with the shell:
|
|
/// - [MainScreen] reports its measured mobile bottom-bar height (and zeroes
|
|
/// it while a pushed detail route covers the bar) so the overlay floats
|
|
/// above the true bottom edge;
|
|
/// - the overlay reports back [overlayHeight] so music screens can pad their
|
|
/// scroll views and keep the last rows reachable.
|
|
class MiniPlayerInsetController extends ChangeNotifier {
|
|
double _navBarInset = 0;
|
|
bool _navBarSuspended = false;
|
|
double _overlayHeight = 0;
|
|
|
|
/// Height of the mobile bottom navigation area the mini-player must clear;
|
|
/// 0 while a pushed route covers it (safe-area padding takes over).
|
|
double get bottomInset => _navBarSuspended ? 0 : _navBarInset;
|
|
|
|
/// Total vertical space the visible mini-player occupies (card + gaps).
|
|
/// 0 while hidden. Music screens add this to their scroll bottom padding.
|
|
double get overlayHeight => _overlayHeight;
|
|
|
|
void setNavBarInset(double value) {
|
|
if (_navBarInset == value) return;
|
|
_navBarInset = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Zero the nav-bar inset while a pushed route covers the bottom bar
|
|
/// (MainScreen's RouteAware didPushNext/didPopNext).
|
|
void setNavBarSuspended(bool value) {
|
|
if (_navBarSuspended == value) return;
|
|
_navBarSuspended = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
void setOverlayHeight(double value) {
|
|
if (_overlayHeight == value) return;
|
|
_overlayHeight = value;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Persistent floating music mini-player, mounted ABOVE the profile
|
|
/// navigator (see `profile_session_screen.dart`) so it survives route
|
|
/// changes. Never rendered on TV — the rail's Now Playing item and the
|
|
/// auto-pushed now-playing screen cover that surface.
|
|
class MusicMiniPlayerOverlay extends StatefulWidget {
|
|
const MusicMiniPlayerOverlay({super.key});
|
|
|
|
@override
|
|
State<MusicMiniPlayerOverlay> createState() => _MusicMiniPlayerOverlayState();
|
|
}
|
|
|
|
class _MusicMiniPlayerOverlayState extends State<MusicMiniPlayerOverlay> {
|
|
static const double _cardHeight = 64;
|
|
static final ValueNotifier<bool> _noSuppression = ValueNotifier<bool>(false);
|
|
|
|
/// Last non-null track, so the exit animation still has content to show.
|
|
MediaItem? _lastTrack;
|
|
|
|
/// Optimistically hides the card the instant a swipe-dismiss lands —
|
|
/// `stop()` tears the audio core down asynchronously and the Dismissible
|
|
/// must leave the tree before that completes.
|
|
bool _dismissed = false;
|
|
|
|
void _reportOverlayHeight(double height) {
|
|
final controller = context.read<MiniPlayerInsetController?>();
|
|
if (controller == null || controller.overlayHeight == height) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) controller.setOverlayHeight(height);
|
|
});
|
|
}
|
|
|
|
void _handleDismissed() {
|
|
setState(() => _dismissed = true);
|
|
unawaited(context.read<MusicPlaybackService>().stop());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (PlatformDetector.isTV()) return const SizedBox.shrink();
|
|
|
|
final track = context.select<MusicPlaybackService, MediaItem?>((s) => s.currentTrack);
|
|
if (track == null) {
|
|
_dismissed = false;
|
|
} else if (_lastTrack != null && track.globalKey != _lastTrack!.globalKey) {
|
|
_dismissed = false;
|
|
}
|
|
if (track != null) _lastTrack = track;
|
|
|
|
// Nothing has ever played this session — skip the whole overlay tree
|
|
// (also keeps barren test shells without MonoTokens happy).
|
|
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 useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
|
_reportOverlayHeight(visible ? _cardHeight + (useSideNav ? 32 : 24) : 0);
|
|
|
|
final Widget child = visible
|
|
? _MiniPlayerCard(
|
|
key: const ValueKey('mini_player_card'),
|
|
track: track,
|
|
desktop: useSideNav,
|
|
onDismissed: _handleDismissed,
|
|
)
|
|
: const SizedBox.shrink(key: ValueKey('mini_player_hidden'));
|
|
|
|
final switcher = AnimatedSwitcher(
|
|
duration: MonoMotion.shape(context),
|
|
switchInCurve: MonoMotion.emphasized,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
transitionBuilder: (child, animation) => FadeTransition(
|
|
opacity: animation,
|
|
child: SlideTransition(
|
|
position: Tween<Offset>(begin: const Offset(0, 0.4), end: Offset.zero).animate(animation),
|
|
child: child,
|
|
),
|
|
),
|
|
child: child,
|
|
);
|
|
|
|
if (useSideNav) {
|
|
return Stack(children: [Positioned(right: 16, bottom: 16, width: 380, child: switcher)]);
|
|
}
|
|
|
|
final inset = context.select<MiniPlayerInsetController?, double>((c) => c?.bottomInset ?? 0);
|
|
final bottom = 12 + (inset > 0 ? inset : MediaQuery.paddingOf(context).bottom);
|
|
return Stack(
|
|
children: [
|
|
AnimatedPositioned(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
left: 12,
|
|
right: 12,
|
|
bottom: bottom,
|
|
child: switcher,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The floating card itself. Takes [track] as data (instead of watching the
|
|
/// service) so the AnimatedSwitcher's exit snapshot never renders against a
|
|
/// cleared session.
|
|
class _MiniPlayerCard extends StatefulWidget {
|
|
final MediaItem track;
|
|
final bool desktop;
|
|
final VoidCallback onDismissed;
|
|
|
|
const _MiniPlayerCard({super.key, required this.track, required this.desktop, required this.onDismissed});
|
|
|
|
@override
|
|
State<_MiniPlayerCard> createState() => _MiniPlayerCardState();
|
|
}
|
|
|
|
class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMixin<_MiniPlayerCard> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tk = tokens(context);
|
|
final service = context.read<MusicPlaybackService>();
|
|
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
|
|
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.track.serverId));
|
|
final artist = widget.track.trackArtistTitle;
|
|
|
|
Widget card = Material(
|
|
color: tk.surface,
|
|
clipBehavior: Clip.antiAlias,
|
|
borderRadius: BorderRadius.circular(tk.radiusLg),
|
|
child: InkWell(
|
|
mouseCursor: SystemMouseCursors.click,
|
|
onTap: () => unawaited(openNowPlaying(context)),
|
|
onTapDown: storeTapPosition,
|
|
onLongPress: showContextMenuFromTap,
|
|
onSecondaryTapDown: storeTapPosition,
|
|
onSecondaryTap: showContextMenuFromTap,
|
|
child: SizedBox(
|
|
height: _MusicMiniPlayerOverlayState._cardHeight,
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(tk.radiusSm),
|
|
child: OptimizedMediaImage(
|
|
client: client,
|
|
imagePath: widget.track.thumbPath,
|
|
imageType: ImageType.square,
|
|
width: 48,
|
|
height: 48,
|
|
fallbackIcon: Symbols.music_note_rounded,
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: .center,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
widget.track.title ?? '',
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
style: TextStyle(fontSize: 14, fontWeight: .w600, color: tk.text),
|
|
),
|
|
if (artist != null && artist.isNotEmpty)
|
|
Text(
|
|
artist,
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
style: TextStyle(fontSize: 12, color: tk.textMuted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (widget.desktop)
|
|
IconButton(
|
|
icon: AppIcon(Symbols.skip_previous_rounded, fill: 1, color: tk.text),
|
|
tooltip: t.music.previousTrack,
|
|
onPressed: () => unawaited(service.previous()),
|
|
),
|
|
IconButton(
|
|
icon: AppIcon(
|
|
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
|
|
fill: 1,
|
|
color: tk.text,
|
|
),
|
|
tooltip: isPlaying ? t.common.pause : t.common.play,
|
|
onPressed: () => unawaited(service.togglePlayPause()),
|
|
),
|
|
IconButton(
|
|
icon: AppIcon(Symbols.skip_next_rounded, fill: 1, color: tk.text),
|
|
tooltip: t.music.nextTrack,
|
|
onPressed: () => unawaited(service.next()),
|
|
),
|
|
if (widget.desktop)
|
|
AnimatedOpacity(
|
|
opacity: _hovered ? 1 : 0,
|
|
duration: tk.fast,
|
|
child: IgnorePointer(
|
|
ignoring: !_hovered,
|
|
child: IconButton(
|
|
icon: AppIcon(Symbols.close_rounded, fill: 1, size: 20, color: tk.textMuted),
|
|
tooltip: t.music.stopPlayback,
|
|
onPressed: widget.onDismissed,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Positioned(left: 0, right: 0, bottom: 0, height: 2, child: _MiniPlayerProgress()),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
card = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(tk.radiusLg),
|
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.35), blurRadius: 16, offset: const Offset(0, 4))],
|
|
),
|
|
child: card,
|
|
);
|
|
|
|
card = MediaContextMenu(key: contextMenuKey, item: widget.track, child: card);
|
|
|
|
card = Dismissible(
|
|
key: const ValueKey('mini_player_dismiss'),
|
|
direction: DismissDirection.horizontal,
|
|
onDismissed: (_) => widget.onDismissed(),
|
|
child: card,
|
|
);
|
|
|
|
if (!widget.desktop) return card;
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: card,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Isolated progress leaf — positionStream ticks rebuild only this 2px line,
|
|
/// never the card above it.
|
|
class _MiniPlayerProgress extends StatelessWidget {
|
|
const _MiniPlayerProgress();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final service = context.read<MusicPlaybackService>();
|
|
final tk = tokens(context);
|
|
return StreamBuilder<Duration>(
|
|
stream: service.positionStream,
|
|
builder: (context, snapshot) {
|
|
final position = snapshot.data ?? service.position;
|
|
final durationMs = service.duration?.inMilliseconds ?? 0;
|
|
final fraction = durationMs <= 0 ? 0.0 : (position.inMilliseconds / durationMs).clamp(0.0, 1.0);
|
|
return Align(
|
|
alignment: .centerLeft,
|
|
child: FractionallySizedBox(
|
|
widthFactor: fraction,
|
|
heightFactor: 1,
|
|
child: ColoredBox(color: tk.text.withValues(alpha: 0.9)),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|