feat(music): now-playing, mini-player, queue and lyrics UI

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.
This commit is contained in:
edde746
2026-07-05 21:50:11 +02:00
parent 422db75b5b
commit 7d6a747aa7
24 changed files with 3069 additions and 217 deletions
+17
View File
@@ -79,4 +79,21 @@ class FocusTheme {
color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
); );
} }
/// Focus background fill derived from the theme's text color, so it stays
/// visible on BOTH light and dark surfaces — the white-based
/// [focusBackgroundDecoration] disappears on light ones. This is the mono
/// convention used by [TrackRow], the navigation rail, and the music player
/// surfaces. Prefer this for any new mono-themed surface.
static BoxDecoration textFillFocusDecoration(
BuildContext context, {
required bool isFocused,
double borderRadius = defaultBorderRadius,
BorderRadius? radii,
}) {
return BoxDecoration(
borderRadius: radii ?? BorderRadius.circular(borderRadius),
color: isFocused ? tokens(context).text.withValues(alpha: 0.12) : Colors.transparent,
);
}
} }
+19 -1
View File
@@ -983,7 +983,23 @@
"trackCount": { "trackCount": {
"one": "${n} track", "one": "${n} track",
"other": "${n} tracks" "other": "${n} tracks"
} },
"nowPlaying": "Now Playing",
"playingFrom": "Playing from ${title}",
"queue": "Queue",
"upNext": "Up next",
"clearQueue": "Clear queue",
"lyrics": "Lyrics",
"noLyrics": "No lyrics available",
"sleepTimer": "Sleep timer",
"sleepTimerEndOfTrack": "End of track",
"sleepTimerMinutes": "${n} minutes",
"stopPlayback": "Stop playback",
"previousTrack": "Previous track",
"nextTrack": "Next track",
"repeat": "Repeat",
"repeatAll": "Repeat all",
"repeatOne": "Repeat one"
}, },
"watchTogether": { "watchTogether": {
"title": "Watch Together", "title": "Watch Together",
@@ -1055,6 +1071,8 @@
"manage": "Manage", "manage": "Manage",
"tvShows": "TV Shows", "tvShows": "TV Shows",
"movies": "Movies", "movies": "Movies",
"music": "Music",
"tracksQueued": "${count} tracks queued for download",
"noDownloads": "No downloads yet", "noDownloads": "No downloads yet",
"noDownloadsDescription": "Downloaded content will appear here for offline viewing", "noDownloadsDescription": "Downloaded content will appear here for offline viewing",
"downloadNow": "Download", "downloadNow": "Download",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 16 /// Locales: 16
/// Strings: 20877 (1304 per locale) /// Strings: 20895 (1305 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+74 -2
View File
@@ -2895,6 +2895,54 @@ class TranslationsMusicEn {
one: '${n} track', one: '${n} track',
other: '${n} tracks', other: '${n} tracks',
); );
/// en: 'Now Playing'
String get nowPlaying => 'Now Playing';
/// en: 'Playing from ${title}'
String playingFrom({required Object title}) => 'Playing from ${title}';
/// en: 'Queue'
String get queue => 'Queue';
/// en: 'Up next'
String get upNext => 'Up next';
/// en: 'Clear queue'
String get clearQueue => 'Clear queue';
/// en: 'Lyrics'
String get lyrics => 'Lyrics';
/// en: 'No lyrics available'
String get noLyrics => 'No lyrics available';
/// en: 'Sleep timer'
String get sleepTimer => 'Sleep timer';
/// en: 'End of track'
String get sleepTimerEndOfTrack => 'End of track';
/// en: '${n} minutes'
String sleepTimerMinutes({required Object n}) => '${n} minutes';
/// en: 'Stop playback'
String get stopPlayback => 'Stop playback';
/// en: 'Previous track'
String get previousTrack => 'Previous track';
/// en: 'Next track'
String get nextTrack => 'Next track';
/// en: 'Repeat'
String get repeat => 'Repeat';
/// en: 'Repeat all'
String get repeatAll => 'Repeat all';
/// en: 'Repeat one'
String get repeatOne => 'Repeat one';
} }
// Path: watchTogether // Path: watchTogether
@@ -3115,6 +3163,12 @@ class TranslationsDownloadsEn {
/// en: 'Movies' /// en: 'Movies'
String get movies => 'Movies'; String get movies => 'Movies';
/// en: 'Music'
String get music => 'Music';
/// en: '${count} tracks queued for download'
String tracksQueued({required Object count}) => '${count} tracks queued for download';
/// en: 'No downloads yet' /// en: 'No downloads yet'
String get noDownloads => 'No downloads yet'; String get noDownloads => 'No downloads yet';
@@ -5566,6 +5620,22 @@ extension on Translations {
'music.addToQueue' => 'Add to queue', 'music.addToQueue' => 'Add to queue',
'music.discNumber' => ({required Object n}) => 'Disc ${n}', 'music.discNumber' => ({required Object n}) => 'Disc ${n}',
'music.trackCount' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} track', other: '${n} tracks', ), 'music.trackCount' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} track', other: '${n} tracks', ),
'music.nowPlaying' => 'Now Playing',
'music.playingFrom' => ({required Object title}) => 'Playing from ${title}',
'music.queue' => 'Queue',
'music.upNext' => 'Up next',
'music.clearQueue' => 'Clear queue',
'music.lyrics' => 'Lyrics',
'music.noLyrics' => 'No lyrics available',
'music.sleepTimer' => 'Sleep timer',
'music.sleepTimerEndOfTrack' => 'End of track',
'music.sleepTimerMinutes' => ({required Object n}) => '${n} minutes',
'music.stopPlayback' => 'Stop playback',
'music.previousTrack' => 'Previous track',
'music.nextTrack' => 'Next track',
'music.repeat' => 'Repeat',
'music.repeatAll' => 'Repeat all',
'music.repeatOne' => 'Repeat one',
'watchTogether.title' => 'Watch Together', 'watchTogether.title' => 'Watch Together',
'watchTogether.description' => 'Watch content in sync with friends and family', 'watchTogether.description' => 'Watch content in sync with friends and family',
'watchTogether.createSession' => 'Create Session', 'watchTogether.createSession' => 'Create Session',
@@ -5633,6 +5703,8 @@ extension on Translations {
'downloads.manage' => 'Manage', 'downloads.manage' => 'Manage',
'downloads.tvShows' => 'TV Shows', 'downloads.tvShows' => 'TV Shows',
'downloads.movies' => 'Movies', 'downloads.movies' => 'Movies',
'downloads.music' => 'Music',
'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download',
'downloads.noDownloads' => 'No downloads yet', 'downloads.noDownloads' => 'No downloads yet',
'downloads.noDownloadsDescription' => 'Downloaded content will appear here for offline viewing', 'downloads.noDownloadsDescription' => 'Downloaded content will appear here for offline viewing',
'downloads.downloadNow' => 'Download', 'downloads.downloadNow' => 'Download',
@@ -5669,6 +5741,8 @@ extension on Translations {
'downloads.customAmount' => 'Custom amount...', 'downloads.customAmount' => 'Custom amount...',
'downloads.includeSpecials' => 'Include Specials', 'downloads.includeSpecials' => 'Include Specials',
'downloads.howManyEpisodes' => 'How many episodes?', 'downloads.howManyEpisodes' => 'How many episodes?',
_ => null,
} ?? switch (path) {
'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download', 'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download',
'downloads.keepSynced' => 'Keep synced', 'downloads.keepSynced' => 'Keep synced',
'downloads.downloadOnce' => 'Download once', 'downloads.downloadOnce' => 'Download once',
@@ -5687,8 +5761,6 @@ extension on Translations {
'downloads.editSyncFilter' => 'Sync filter', 'downloads.editSyncFilter' => 'Sync filter',
'downloads.syncAllItems' => 'Syncing all items', 'downloads.syncAllItems' => 'Syncing all items',
'downloads.syncUnwatchedItems' => 'Syncing unwatched items', 'downloads.syncUnwatchedItems' => 'Syncing unwatched items',
_ => null,
} ?? switch (path) {
'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}', 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server}${status}',
'downloads.syncRuleAvailable' => 'Available', 'downloads.syncRuleAvailable' => 'Available',
'downloads.syncRuleOffline' => 'Offline', 'downloads.syncRuleOffline' => 'Offline',
+27 -4
View File
@@ -28,6 +28,7 @@ import '../services/offline_watch_sync_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../watch_together/providers/watch_together_provider.dart'; import '../watch_together/providers/watch_together_provider.dart';
import '../widgets/music/mini_player.dart';
import 'profile_navigation_scope.dart'; import 'profile_navigation_scope.dart';
/// Root route for an active profile session. /// Root route for an active profile session.
@@ -228,6 +229,12 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
final _mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final _mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _routeObserver = RouteObserver<PageRoute<dynamic>>(); final _routeObserver = RouteObserver<PageRoute<dynamic>>();
// Music mini-player wiring: the route observer hides the overlay while the
// video player / now-playing screen is up; the inset controller lets
// MainScreen report its bottom-bar height so the overlay floats above it.
final _musicRouteObserver = MusicUiRouteObserver();
final _miniPlayerInsets = MiniPlayerInsetController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -239,6 +246,8 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
void dispose() { void dispose() {
profileNavigationRegistry.detachNavigator(_navigatorKey); profileNavigationRegistry.detachNavigator(_navigatorKey);
profileNavigationRegistry.detachMainScaffoldMessenger(_mainScaffoldMessengerKey); profileNavigationRegistry.detachMainScaffoldMessenger(_mainScaffoldMessengerKey);
_miniPlayerInsets.dispose();
_musicRouteObserver.suppress.dispose();
super.dispose(); super.dispose();
} }
@@ -254,10 +263,24 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> {
if (didPop) return; if (didPop) return;
unawaited(_navigatorKey.currentState?.maybePop()); unawaited(_navigatorKey.currentState?.maybePop());
}, },
child: Navigator( child: MultiProvider(
key: _navigatorKey, providers: [
observers: [_routeObserver, BackKeySuppressorObserver()], ChangeNotifierProvider<MiniPlayerInsetController>.value(value: _miniPlayerInsets),
onGenerateRoute: _onGenerateRoute, Provider<MusicUiRouteObserver>.value(value: _musicRouteObserver),
],
// The mini-player mounts ABOVE the nested navigator so it persists
// across content routes (but inside the profile provider scope so
// it dies with the session).
child: Stack(
children: [
Navigator(
key: _navigatorKey,
observers: [_routeObserver, _musicRouteObserver, BackKeySuppressorObserver()],
onGenerateRoute: _onGenerateRoute,
),
const Positioned.fill(child: MusicMiniPlayerOverlay()),
],
),
), ),
), ),
); );
+29
View File
@@ -50,6 +50,7 @@ import '../services/companion_remote/companion_remote_receiver.dart';
import '../services/fullscreen_state_manager.dart'; import '../services/fullscreen_state_manager.dart';
import '../providers/companion_remote_provider.dart'; import '../providers/companion_remote_provider.dart';
import '../utils/desktop_window_padding.dart'; import '../utils/desktop_window_padding.dart';
import '../widgets/music/mini_player.dart';
import '../widgets/side_navigation_rail.dart'; import '../widgets/side_navigation_rail.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
@@ -224,6 +225,10 @@ class _MainScreenState extends State<MainScreen>
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey(); final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey(); final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
/// Measures the mobile bottom navigation area for the music mini-player.
final GlobalKey _bottomBarKey = GlobalKey();
MiniPlayerInsetController? _miniPlayerInsets;
// Focus management for sidebar/content switching // Focus management for sidebar/content switching
final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar');
final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content');
@@ -746,6 +751,8 @@ class _MainScreenState extends State<MainScreen>
_setupCompanionRemote(); _setupCompanionRemote();
} }
_miniPlayerInsets = context.read<MiniPlayerInsetController?>();
final scopedRouteObserver = ProfileNavigationScope.of(context).routeObserver; final scopedRouteObserver = ProfileNavigationScope.of(context).routeObserver;
if (scopedRouteObserver != _profileRouteObserver) { if (scopedRouteObserver != _profileRouteObserver) {
_profileRouteObserver?.unsubscribe(this); _profileRouteObserver?.unsubscribe(this);
@@ -1281,6 +1288,9 @@ class _MainScreenState extends State<MainScreen>
@override @override
void didPushNext() { void didPushNext() {
_setTvosMenuPassthrough(false); _setTvosMenuPassthrough(false);
// A pushed detail route covers the bottom bar — drop the mini-player to
// the true (safe-area) bottom while it's hidden.
_miniPlayerInsets?.setNavBarSuspended(true);
// Called when a child route is pushed on top (e.g., video player) // Called when a child route is pushed on top (e.g., video player)
if (_currentTab == NavigationTabId.discover) { if (_currentTab == NavigationTabId.discover) {
if (_discoverKey.currentState case final TabVisibilityAware aware) { if (_discoverKey.currentState case final TabVisibilityAware aware) {
@@ -1302,6 +1312,7 @@ class _MainScreenState extends State<MainScreen>
// Called when returning to this route from a child route (e.g., from video player) // Called when returning to this route from a child route (e.g., from video player)
_updateTvosMenuPassthrough(); _updateTvosMenuPassthrough();
_miniPlayerInsets?.setNavBarSuspended(false);
if (_currentTab == NavigationTabId.discover) { if (_currentTab == NavigationTabId.discover) {
if (_discoverKey.currentState case final TabVisibilityAware aware) { if (_discoverKey.currentState case final TabVisibilityAware aware) {
aware.onTabShown(); aware.onTabShown();
@@ -1589,6 +1600,19 @@ class _MainScreenState extends State<MainScreen>
); );
} }
/// Report the mobile bottom bar's rendered height to the mini-player inset
/// controller after this frame (the bar mixes NavigationBar, optional
/// offline banner, and label modes — measuring beats re-deriving).
void _scheduleBottomBarMeasure() {
final controller = _miniPlayerInsets;
if (controller == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final box = _bottomBarKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null && box.hasSize) controller.setNavBarInset(box.size.height);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useSideNav = PlatformDetector.shouldUseSideNavigation(context); final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
@@ -1732,6 +1756,7 @@ class _MainScreenState extends State<MainScreen>
child: Scaffold( child: Scaffold(
body: _buildTickerAwareStack(), body: _buildTickerAwareStack(),
bottomNavigationBar: Column( bottomNavigationBar: Column(
key: _bottomBarKey,
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
// Reconnect bar when offline // Reconnect bar when offline
@@ -1774,6 +1799,10 @@ class _MainScreenState extends State<MainScreen>
pref: SettingsService.showNavBarLabels, pref: SettingsService.showNavBarLabels,
builder: (context, showNavBarLabels, _) { builder: (context, showNavBarLabels, _) {
final hideLabels = !showNavBarLabels; final hideLabels = !showNavBarLabels;
// Re-measure whenever the bar's composition can change:
// this builder reruns on label toggles AND on every
// MainScreen rebuild (offline bar appearing/disappearing).
_scheduleBottomBarMeasure();
return NavigationBarTheme( return NavigationBarTheme(
data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null),
child: _buildBottomNavigationBar(context, hideLabels: hideLabels), child: _buildBottomNavigationBar(context, hideLabels: hideLabels),
+104
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focus_theme.dart'; import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart'; import '../../focus/focusable_action_bar.dart';
@@ -11,9 +12,12 @@ import '../../i18n/strings.g.dart';
import '../../media/ids.dart'; import '../../media/ids.dart';
import '../../media/media_item.dart'; import '../../media/media_item.dart';
import '../../mixins/grid_focus_node_mixin.dart'; import '../../mixins/grid_focus_node_mixin.dart';
import '../../models/download_models.dart';
import '../../providers/download_provider.dart';
import '../../services/music/music_playback_service.dart'; import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart'; import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/dialogs.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../utils/layout_constants.dart'; import '../../utils/layout_constants.dart';
import '../../utils/media_image_helper.dart'; import '../../utils/media_image_helper.dart';
@@ -23,8 +27,10 @@ import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart'; import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../../widgets/download_status_icon.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/media_context_menu.dart'; import '../../widgets/media_context_menu.dart';
import '../../widgets/music/mini_player.dart';
import '../../widgets/music/music_actions.dart'; import '../../widgets/music/music_actions.dart';
import '../../widgets/music/track_row.dart'; import '../../widgets/music/track_row.dart';
import '../../widgets/optimized_media_image.dart'; import '../../widgets/optimized_media_image.dart';
@@ -149,6 +155,98 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
); );
} }
/// Queue the album (expands to its tracks) or, when fully downloaded,
/// offer deletion. Queued/downloading states are inert — the button just
/// reflects progress.
Future<void> _handleDownloadPressed() async {
final downloadProvider = context.read<DownloadProvider>();
final globalKey = widget.album.globalKey;
final progress = downloadProvider.getProgress(globalKey);
if (downloadProvider.isQueueing(globalKey) ||
progress?.status == DownloadStatus.queued ||
progress?.status == DownloadStatus.downloading) {
return;
}
if (downloadProvider.isDownloaded(globalKey)) {
final confirmed = await showDeleteConfirmation(
context,
title: t.downloads.deleteDownload,
message: t.downloads.deleteConfirm(title: widget.album.displayTitle),
);
if (!confirmed || !mounted) return;
await downloadProvider.deleteDownload(globalKey);
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
return;
}
// Not downloaded (or partial/failed): queue the album — already-active
// tracks are skipped inside the provider, so this also fills gaps.
try {
final count = await downloadProvider.queueDownload(widget.album, mediaClient);
if (!mounted) return;
showSuccessSnackBar(context, count > 1 ? t.downloads.tracksQueued(count: count) : t.downloads.downloadQueued);
} on CellularDownloadBlockedException {
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
} catch (e) {
appLogger.e('Failed to queue album download', error: e);
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
/// Album download button — mirrors the media-detail download action's
/// states in compact form. Hidden on Apple TV (no user-accessible storage)
/// and when no [DownloadProvider] is in scope.
FocusableAction? _downloadAction() {
if (PlatformDetector.isAppleTV()) return null;
if (widget.album.serverId == null || context.read<DownloadProvider?>() == null) return null;
return FocusableAction(
debugLabel: 'album_download',
onPressed: () => unawaited(_handleDownloadPressed()),
builder: (context, state) => Consumer<DownloadProvider>(
builder: (context, downloadProvider, _) {
final globalKey = widget.album.globalKey;
final progress = downloadProvider.getProgress(globalKey);
final isQueueing = downloadProvider.isQueueing(globalKey);
final status = progress?.status;
final Widget icon;
final String tooltip;
if (isQueueing) {
icon = const DownloadQueueingSpinner(size: 20);
tooltip = t.downloads.downloadingTooltip;
} else if (status == DownloadStatus.queued) {
icon = const AppIcon(Symbols.schedule_rounded, fill: 1);
tooltip = t.downloads.queuedTooltip;
} else if (status == DownloadStatus.downloading) {
icon = DownloadStatusIcon(
status: DownloadStatus.downloading,
size: 20,
progress: progress?.progressPercent,
);
tooltip = t.downloads.downloadingTooltip;
} else if (status == DownloadStatus.completed) {
icon = const AppIcon(Symbols.download_done_rounded, fill: 1);
tooltip = t.downloads.deleteDownload;
} else if (status == DownloadStatus.partial) {
icon = const AppIcon(Symbols.downloading_rounded, fill: 1);
tooltip = t.downloads.partialDownloadClickToComplete;
} else {
icon = const AppIcon(Symbols.download_rounded, fill: 1);
tooltip = t.downloads.downloadNow;
}
return Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: state.showFocus, borderRadius: 20),
child: IconButton(icon: icon, tooltip: tooltip, onPressed: () => unawaited(_handleDownloadPressed())),
);
},
),
);
}
@override @override
List<FocusableAction> getAppBarActions() { List<FocusableAction> getAppBarActions() {
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId)); final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId));
@@ -158,6 +256,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
onInstantMix: (client?.capabilities.instantMix ?? false) onInstantMix: (client?.capabilities.instantMix ?? false)
? () => unawaited(playInstantMix(context, widget.album)) ? () => unawaited(playInstantMix(context, widget.album))
: null, : null,
download: _downloadAction(),
trailing: _overflowAction(), trailing: _overflowAction(),
); );
} }
@@ -301,6 +400,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
item: item, item: item,
isFirst: row.isFirst, isFirst: row.isFirst,
isLast: row.isLast, isLast: row.isLast,
showDownloadStatus: true,
focusNode: focusNodeForIndex(trackIndex, firstItemFocusNode, prefix: 'detail_grid_item'), focusNode: focusNodeForIndex(trackIndex, firstItemFocusNode, prefix: 'detail_grid_item'),
onNavigateUp: trackIndex == 0 ? navigateToAppBar : null, onNavigateUp: trackIndex == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent, onBack: handleBackFromContent,
@@ -336,6 +436,10 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
SliverToBoxAdapter(child: _buildHeader()), SliverToBoxAdapter(child: _buildHeader()),
...buildStateSlivers(), ...buildStateSlivers(),
if (hasItems) _buildTrackList(), if (hasItems) _buildTrackList(),
// Keep the last rows reachable above the floating mini-player.
SliverToBoxAdapter(
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
),
], ],
), ),
), ),
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_action_bar.dart'; import '../../focus/focusable_action_bar.dart';
import '../../focus/key_event_utils.dart'; import '../../focus/key_event_utils.dart';
@@ -20,6 +21,7 @@ import '../../utils/snackbar_helper.dart';
import '../../widgets/collapsible_text.dart'; import '../../widgets/collapsible_text.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/music/mini_player.dart';
import '../../widgets/music/music_actions.dart'; import '../../widgets/music/music_actions.dart';
import '../../widgets/optimized_media_image.dart'; import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart'; import '../../widgets/overlay_sheet.dart';
@@ -198,6 +200,10 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
...buildStateSlivers(), ...buildStateSlivers(),
// Albums arrive newest-first from both backends — no client-side sort. // Albums arrive newest-first from both backends — no client-side sort.
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square), if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
// Keep the last rows reachable above the floating mini-player.
SliverToBoxAdapter(
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
),
], ],
), ),
), ),
+983
View File
@@ -0,0 +1,983 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../media/ids.dart';
import '../../media/lyrics.dart';
import '../../media/media_item.dart';
import '../../media/media_server_client.dart';
import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/device_performance.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_motion.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/formatters.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/music_navigation.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/app_menu.dart';
import '../../widgets/media_context_menu.dart';
import '../../widgets/music/lyrics_view.dart';
import '../../widgets/music/repeat_mode.dart';
import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
import 'queue_sheet.dart';
/// Full-screen music player. Pushed via [openNowPlaying]; popping never
/// touches playback — audio continues under the mini-player.
///
/// Layouts: mobile portrait (big art / transport stack), desktop & landscape
/// (two-pane with an inline queue panel), and TV (full-bleed art with a
/// d-pad transport chain).
///
/// D-pad chain (TV): transport row autofocuses play/pause (LEFT/RIGHT rove,
/// edges trapped) · UP → seek bar (LEFT/RIGHT = ±10s with key-repeat
/// acceleration) · seek UP → ⋮ overflow · transport DOWN → utility row
/// (Lyrics, Queue) · Lyrics toggle focuses the lyrics pane (BACK/DOWN-out
/// returns to transport) · BACK pops. Focus is always a text-based
/// background fill.
class NowPlayingScreen extends StatefulWidget {
const NowPlayingScreen({super.key});
@override
State<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends State<NowPlayingScreen> with ContextMenuTapMixin<NowPlayingScreen> {
MusicPlaybackService? _service;
StreamSubscription<Object>? _errorsSub;
bool _showLyrics = false;
final Map<String, Future<Lyrics?>> _lyricsCache = {};
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');
final FocusNode _lyricsPaneFocusNode = FocusNode(debugLabel: 'now_playing_lyrics_pane');
final GlobalKey<FocusableActionBarState> _utilityBarKey = GlobalKey<FocusableActionBarState>();
bool _overflowFocused = false;
bool _poppedForIdle = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final service = context.read<MusicPlaybackService>();
if (service != _service) {
_errorsSub?.cancel();
_service = service;
// Surface playback failures while the screen is open — the service
// already recovers (skip / stop) by itself.
_errorsSub = service.errors.listen((error) {
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: error.toString()));
});
}
}
@override
void dispose() {
_errorsSub?.cancel();
_seekFocusNode.dispose();
_overflowFocusNode.dispose();
_playPauseFocusNode.dispose();
_lyricsPaneFocusNode.dispose();
super.dispose();
}
// -------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------
Future<Lyrics?> _lyricsFutureFor(MediaItem track) =>
_lyricsCache.putIfAbsent(track.globalKey, () => context.read<MusicPlaybackService>().fetchLyrics(track));
void _toggleLyrics() {
setState(() => _showLyrics = !_showLyrics);
if (_showLyrics && InputModeTracker.isKeyboardMode(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showLyrics) _lyricsPaneFocusNode.requestFocus();
});
}
}
void _focusTransport() => _playPauseFocusNode.requestFocus();
void _pop() => Navigator.pop(context);
/// 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 {
final artistId = track.grandparentId;
final client = context.getMediaClientForItemOrNull(track);
if (artistId == null || client == null) return;
MediaItem? artist;
try {
artist = await client.fetchItem(artistId);
} catch (e) {
appLogger.w('Failed to fetch artist $artistId for track ${track.id}', error: e);
}
if (artist == null || !mounted) return;
await navigateToArtist(context, artist);
}
Future<void> _showSleepTimerSheet() async {
final service = context.read<MusicPlaybackService>();
final timed = service.sleepTimerActive && !service.sleepTimerEndOfTrack;
final selected = await OverlaySheetController.showAdaptive<String>(
context,
showDragHandle: true,
builder: (context) => AppMenuSheet<String>(
title: t.music.sleepTimer,
entries: [
AppMenuItem(
value: 'off',
icon: Symbols.timer_off_rounded,
label: t.common.off,
selected: !service.sleepTimerActive,
),
for (final minutes in const [15, 30, 60])
AppMenuItem(
value: '$minutes',
icon: Symbols.timer_rounded,
label: t.music.sleepTimerMinutes(n: minutes),
selected: timed,
),
AppMenuItem(
value: 'end_of_track',
icon: Symbols.music_note_rounded,
label: t.music.sleepTimerEndOfTrack,
selected: service.sleepTimerEndOfTrack,
),
],
),
);
if (!mounted || selected == null) return;
switch (selected) {
case 'off':
service.setSleepTimer(null);
case 'end_of_track':
service.setSleepTimer(null, endOfTrack: true);
default:
final minutes = int.tryParse(selected);
if (minutes != null) service.setSleepTimer(Duration(minutes: minutes));
}
}
// -------------------------------------------------------------------
// Build
// -------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final track = service.currentTrack;
if (track == null) {
// Session ended elsewhere (stop / error / video claimed audio) — an
// empty player is a dead end, leave the screen.
if (!_poppedForIdle) {
_poppedForIdle = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && Navigator.canPop(context)) Navigator.pop(context);
});
}
return Scaffold(backgroundColor: tk.bg, body: const SizedBox.expand());
}
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(track.serverId));
final isTV = PlatformDetector.isTV();
Widget content = Stack(
fit: StackFit.expand,
children: [
_Background(track: track, client: client, heavyScrim: isTV),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
if (isTV) return _buildTvLayout(service, track, client);
final twoPane = constraints.maxWidth >= 800 && constraints.maxWidth > constraints.maxHeight;
return twoPane ? _buildWideLayout(service, track, client) : _buildPortraitLayout(service, track, client);
},
),
),
],
);
if (PlatformDetector.isDesktopOS() && !isTV) {
content = CallbackShortcuts(
bindings: {const SingleActivator(LogicalKeyboardKey.space): () => unawaited(service.togglePlayPause())},
child: FocusScope(child: Focus(autofocus: true, skipTraversal: true, child: content)),
);
}
// Own OverlaySheetHost so the queue / sleep-timer sheets have a host on
// TV; the host also owns system back (a back with a sheet open closes
// the sheet, otherwise the route pops natively — audio continues).
return OverlaySheetHost(
canPop: true,
child: Scaffold(backgroundColor: tk.bg, body: content),
);
}
// -------------------------------------------------------------------
// Layouts
// -------------------------------------------------------------------
Widget _buildPortraitLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
return Column(
children: [
_buildTopBar(track, service.playContext?.title),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: _buildArtworkOrLyrics(service, track, client),
),
),
Padding(padding: const EdgeInsets.fromLTRB(32, 8, 32, 0), child: _buildTrackInfo(track, centered: true)),
Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _buildSeekBar()),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: true),
const SizedBox(height: 8),
],
);
}
Widget _buildWideLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
final tk = tokens(context);
return Column(
children: [
_buildTopBar(track, service.playContext?.title),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
child: Row(
crossAxisAlignment: .stretch,
children: [
Expanded(flex: 9, child: _buildArtworkOrLyrics(service, track, client)),
const SizedBox(width: 32),
Expanded(
flex: 11,
child: Column(
crossAxisAlignment: .stretch,
children: [
_buildTrackInfo(track, centered: false),
const SizedBox(height: 8),
_buildSeekBar(),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: false),
const SizedBox(height: 12),
// Inline queue panel — same widget the queue sheet uses.
Expanded(
child: Material(
color: tk.bg.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(tk.radiusLg),
clipBehavior: Clip.antiAlias,
child: const QueueList(),
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildTvLayout(MusicPlaybackService service, MediaItem track, MediaServerClient? client) {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final playContextTitle = service.playContext?.title;
final artist = track.trackArtistTitle;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 56, vertical: 40),
child: Row(
children: [
Expanded(flex: 4, child: _buildArtworkOrLyrics(service, track, client, tvArt: true)),
const SizedBox(width: 48),
Expanded(
flex: 5,
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .stretch,
children: [
Row(
children: [
Expanded(
child: playContextTitle == null || playContextTitle.isEmpty
? const SizedBox.shrink()
: Text(
t.music.playingFrom(title: playContextTitle),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 14, color: tk.textMuted),
),
),
_buildOverflowButton(track, focusable: true),
],
),
const SizedBox(height: 8),
Text(
track.title ?? '',
maxLines: 2,
overflow: .ellipsis,
style: textTheme.headlineMedium?.copyWith(fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artist,
maxLines: 1,
overflow: .ellipsis,
style: textTheme.bodyLarge?.copyWith(color: tk.textMuted),
),
],
const SizedBox(height: 28),
_buildSeekBar(),
const SizedBox(height: 8),
_buildTransportRow(service),
_buildUtilityRow(showQueueButton: true),
],
),
),
],
),
);
}
// -------------------------------------------------------------------
// Pieces
// -------------------------------------------------------------------
/// [playContextTitle] is passed in (not selected) because this builds
/// inside the layout-phase LayoutBuilder, where `context.select` on the
/// screen's element asserts; the screen already watches the service.
Widget _buildTopBar(MediaItem track, String? playContextTitle) {
final tk = tokens(context);
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),
],
),
);
}
/// ⋮ — 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}) {
final tk = tokens(context);
final button = IconButton(
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, color: tk.text),
onPressed: () => contextMenuKey.currentState?.showContextMenu(context),
);
Widget child = button;
if (focusable) {
final showFocus = _overflowFocused && InputModeTracker.isKeyboardMode(context);
child = Focus(
focusNode: _overflowFocusNode,
descendantsAreFocusable: false,
onFocusChange: (hasFocus) => setState(() => _overflowFocused = hasFocus),
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, _pop);
if (backResult != KeyEventResult.ignored) return backResult;
return dpadKeyHandler(
onSelect: () => contextMenuKey.currentState?.showContextMenu(context),
onDown: _seekFocusNode.requestFocus,
onUp: () {}, // top of the chain — trap
trapHorizontalEdges: true,
)(node, event);
},
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: 20),
child: button,
),
);
}
return MediaContextMenu(
key: contextMenuKey,
item: track,
extraEntries: [
MediaMenuExtraEntry(icon: Symbols.bedtime_rounded, label: t.music.sleepTimer, onSelected: _showSleepTimerSheet),
],
child: child,
);
}
Widget _buildTrackInfo(MediaItem track, {required bool centered}) {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final artist = track.trackArtistTitle;
return Column(
crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start,
children: [
Text(
track.title ?? '',
maxLines: 1,
overflow: .ellipsis,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: textTheme.headlineSmall?.copyWith(fontWeight: .w600, color: tk.text),
),
if (artist != null && artist.isNotEmpty)
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => unawaited(_openArtist(track)),
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
artist,
maxLines: 1,
overflow: .ellipsis,
textAlign: centered ? TextAlign.center : TextAlign.start,
style: textTheme.bodyMedium?.copyWith(color: tk.textMuted),
),
),
),
),
],
);
}
Widget _buildArtworkOrLyrics(
MusicPlaybackService service,
MediaItem track,
MediaServerClient? client, {
bool tvArt = false,
}) {
return AnimatedSwitcher(
duration: MonoMotion.fill(context),
child: _showLyrics
? KeyedSubtree(
key: const ValueKey('now_playing_lyrics'),
child: LyricsView(
lyricsFuture: _lyricsFutureFor(track),
focusNode: _lyricsPaneFocusNode,
onExit: _focusTransport,
),
)
: KeyedSubtree(
key: const ValueKey('now_playing_art'),
child: _Artwork(track: track, client: client, tvSized: tvArt),
),
);
}
Widget _buildSeekBar() {
return _NowPlayingSeekBar(
focusNode: _seekFocusNode,
onNavigateUp: PlatformDetector.isTV() ? _overflowFocusNode.requestFocus : null,
onNavigateDown: _focusTransport,
onBack: _pop,
);
}
/// One transport icon with the shared text-fill focus pill.
Widget _transportIcon(
FocusableActionBuildState state, {
required IconData icon,
required VoidCallback onPressed,
bool active = true,
String? tooltip,
double size = 26,
}) {
final tk = tokens(context);
return AnimatedContainer(
duration: state.animationDuration,
decoration: FocusTheme.textFillFocusDecoration(
context,
isFocused: state.showFocus,
borderRadius: MonoTokens.radiusFull,
),
child: IconButton(
icon: AppIcon(icon, fill: 1, size: size, color: active ? tk.text : tk.textMuted),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildTransportRow(MusicPlaybackService service) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
// Scale down instead of overflowing when the hosting column is
// narrower than the row's intrinsic width (e.g. TV layout on a
// narrow display or a small desktop window).
child: FittedBox(
fit: BoxFit.scaleDown,
child: FocusableActionBar(
spacing: 8,
onNavigateUp: _seekFocusNode.requestFocus,
onNavigateDown: () => _utilityBarKey.currentState?.requestFocusOnFirst(),
onBack: _pop,
actions: [
FocusableAction(
debugLabel: 'np_shuffle',
onPressed: service.toggleShuffle,
builder: (context, state) => _transportIcon(
state,
icon: Symbols.shuffle_rounded,
active: service.shuffled,
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
size: 22,
),
),
FocusableAction(
debugLabel: 'np_previous',
onPressed: () => unawaited(service.previous()),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.skip_previous_rounded,
tooltip: t.music.previousTrack,
onPressed: () => unawaited(service.previous()),
size: 32,
),
),
FocusableAction(
debugLabel: 'np_play_pause',
focusNode: _playPauseFocusNode,
autofocus: PlatformDetector.isTV(),
onPressed: () => unawaited(service.togglePlayPause()),
builder: (context, state) =>
_PlayPauseButton(state: state, onPressed: () => unawaited(service.togglePlayPause())),
),
FocusableAction(
debugLabel: 'np_next',
onPressed: () => unawaited(service.next()),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.skip_next_rounded,
tooltip: t.music.nextTrack,
onPressed: () => unawaited(service.next()),
size: 32,
),
),
FocusableAction(
debugLabel: 'np_repeat',
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
builder: (context, state) => _transportIcon(
state,
icon: repeatModeIcon(service.repeatMode),
active: service.repeatMode != MusicRepeatMode.off,
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
size: 22,
),
),
],
),
),
),
);
}
Widget _buildUtilityRow({required bool showQueueButton}) {
return Center(
child: FocusableActionBar(
key: _utilityBarKey,
spacing: 16,
onNavigateUp: _focusTransport,
// Bottom of the chain — trap DOWN so focus can't escape the screen.
// ignore: no-empty-block
onNavigateDown: () {},
onBack: _pop,
actions: [
FocusableAction(
debugLabel: 'np_lyrics',
onPressed: _toggleLyrics,
builder: (context, state) => _transportIcon(
state,
icon: Symbols.lyrics_rounded,
active: _showLyrics,
tooltip: t.music.lyrics,
onPressed: _toggleLyrics,
size: 22,
),
),
if (showQueueButton)
FocusableAction(
debugLabel: 'np_queue',
onPressed: () => unawaited(showQueueSheet(context)),
builder: (context, state) => _transportIcon(
state,
icon: Symbols.queue_music_rounded,
active: false,
tooltip: t.music.queue,
onPressed: () => unawaited(showQueueSheet(context)),
size: 22,
),
),
],
),
);
}
}
// ---------------------------------------------------------------------
// Artwork
// ---------------------------------------------------------------------
/// Square artwork with the M3E pause shape-morph: large-radius while
/// playing, ~28 when paused.
class _Artwork extends StatelessWidget {
final MediaItem track;
final MediaServerClient? client;
/// TV sizes the art off the screen height instead of hugging width.
final bool tvSized;
const _Artwork({required this.track, required this.client, required this.tvSized});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
return LayoutBuilder(
builder: (context, constraints) {
final side = tvSized
? math.min(constraints.maxWidth, constraints.maxHeight * 0.62)
: math.min(constraints.maxWidth * 0.85, constraints.maxHeight);
if (side <= 0 || !side.isFinite) return const SizedBox.shrink();
return Center(
child: AnimatedContainer(
duration: MonoMotion.shape(context),
curve: MonoMotion.emphasized,
width: side,
height: side,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(isPlaying ? tk.radiusLg : 28)),
child: OptimizedMediaImage(
client: client,
imagePath: track.thumbPath,
imageType: ImageType.square,
width: side,
height: side,
fallbackIcon: Symbols.music_note_rounded,
),
),
);
},
);
}
}
// ---------------------------------------------------------------------
// Background
// ---------------------------------------------------------------------
/// Blurred album art at low opacity under a bg scrim. Skipped entirely on
/// the reduced performance tier (plain background).
class _Background extends StatelessWidget {
final MediaItem track;
final MediaServerClient? client;
final bool heavyScrim;
const _Background({required this.track, required this.client, required this.heavyScrim});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
if (DevicePerformance.isReduced || track.thumbPath == null) {
return ColoredBox(color: tk.bg);
}
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: tk.bg),
Opacity(
opacity: heavyScrim ? 0.22 : 0.3,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 60, sigmaY: 60, tileMode: TileMode.mirror),
child: OptimizedMediaImage(
client: client,
imagePath: track.thumbPath,
imageType: ImageType.square,
fit: BoxFit.cover,
),
),
),
],
);
}
}
// ---------------------------------------------------------------------
// Play / pause morph button
// ---------------------------------------------------------------------
/// 72px inverse-surface button: circle while paused, rounded square while
/// playing (M3E shape morph). Focus reads through the action bar's dimming
/// plus a small scale.
class _PlayPauseButton extends StatelessWidget {
final FocusableActionBuildState state;
final VoidCallback onPressed;
const _PlayPauseButton({required this.state, required this.onPressed});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final colorScheme = Theme.of(context).colorScheme;
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
final isLoading = context.select<MusicPlaybackService, bool>((s) => s.status == MusicPlaybackStatus.loading);
return AnimatedScale(
scale: state.showFocus ? 1.08 : 1.0,
duration: state.animationDuration,
child: AnimatedContainer(
duration: MonoMotion.shape(context),
curve: MonoMotion.emphasized,
width: 72,
height: 72,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(isPlaying ? tk.radiusLg + 4 : 36),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
mouseCursor: SystemMouseCursors.click,
onTap: onPressed,
child: Center(
child: isLoading
? SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(strokeWidth: 2.5, color: colorScheme.onInverseSurface),
)
: AppIcon(
isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
fill: 1,
size: 36,
color: colorScheme.onInverseSurface,
),
),
),
),
),
);
}
}
// ---------------------------------------------------------------------
// Seek bar
// ---------------------------------------------------------------------
/// Thin mono slider driven by the position stream — an isolated widget so
/// per-second ticks never rebuild the screen. The thumb only appears while
/// dragging. As a d-pad region, LEFT/RIGHT seek ±10s with the video
/// timeline's stepped key-repeat acceleration; focus renders as a
/// text-based background pill behind the bar.
class _NowPlayingSeekBar extends StatefulWidget {
final FocusNode focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback onBack;
const _NowPlayingSeekBar({
required this.focusNode,
required this.onNavigateUp,
required this.onNavigateDown,
required this.onBack,
});
@override
State<_NowPlayingSeekBar> createState() => _NowPlayingSeekBarState();
}
class _NowPlayingSeekBarState extends State<_NowPlayingSeekBar> {
static const int _baseStepMs = 10000;
double? _dragValueMs;
bool _focused = false;
int _seekRepeatCount = 0;
LogicalKeyboardKey? _seekDirection;
Duration? _keySeekTarget;
/// Stepped acceleration tiers, mirroring the video timeline's key-repeat
/// scrubbing.
double _seekMultiplier() {
if (_seekRepeatCount <= 5) return 1.5;
if (_seekRepeatCount <= 15) return 3.0;
if (_seekRepeatCount <= 30) return 6.0;
return 10.0;
}
void _resetSeekState() {
_seekRepeatCount = 0;
_seekDirection = null;
_keySeekTarget = null;
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey;
if (event is KeyUpEvent && (key.isLeftKey || key.isRightKey)) {
_resetSeekState();
return KeyEventResult.handled;
}
final backResult = handleBackKeyAction(event, widget.onBack);
if (backResult != KeyEventResult.ignored) return backResult;
if (!event.isActionable) return KeyEventResult.ignored;
if (key.isUpKey) {
if (widget.onNavigateUp == null) return KeyEventResult.handled; // trap
widget.onNavigateUp!();
return KeyEventResult.handled;
}
if (key.isDownKey) {
widget.onNavigateDown?.call();
return KeyEventResult.handled;
}
if (key.isLeftKey || key.isRightKey) {
final service = context.read<MusicPlaybackService>();
final duration = service.duration;
if (duration == null || duration.inMilliseconds <= 0) return KeyEventResult.handled;
if (_seekDirection != key) {
_seekDirection = key;
_seekRepeatCount = 0;
}
if (event is KeyRepeatEvent) _seekRepeatCount++;
final multiplier = event is KeyRepeatEvent ? _seekMultiplier() : 1.0;
final stepMs = (_baseStepMs * multiplier).round();
// Step from the in-flight target during a held burst — the position
// stream lags behind the seeks.
final base = _keySeekTarget ?? service.position;
final targetMs = (base.inMilliseconds + (key.isRightKey ? stepMs : -stepMs)).clamp(0, duration.inMilliseconds);
final target = Duration(milliseconds: targetMs);
_keySeekTarget = target;
unawaited(service.seek(target));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.read<MusicPlaybackService>();
final showFocus = _focused && InputModeTracker.isKeyboardMode(context);
final bar = StreamBuilder<Duration>(
stream: service.positionStream,
builder: (context, snapshot) {
final duration = service.duration ?? Duration.zero;
final durationMs = duration.inMilliseconds.toDouble();
final hasDuration = durationMs > 0;
final rawPositionMs = _dragValueMs ?? (snapshot.data ?? service.position).inMilliseconds.toDouble();
final positionMs = hasDuration ? rawPositionMs.clamp(0.0, durationMs) : 0.0;
final dragging = _dragValueMs != null;
return Column(
mainAxisSize: .min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: tk.text,
inactiveTrackColor: tk.outline,
thumbColor: tk.text,
trackGap: dragging ? 4 : 0,
thumbSize: const WidgetStatePropertyAll(Size(4, 18)),
thumbShape: dragging ? null : SliderComponentShape.noThumb,
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
max: hasDuration ? durationMs : 1,
value: positionMs,
onChangeStart: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChanged: hasDuration ? (value) => setState(() => _dragValueMs = value) : null,
onChangeEnd: hasDuration
? (value) {
unawaited(service.seek(Duration(milliseconds: value.round())));
setState(() => _dragValueMs = null);
}
: null,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 2, 24, 0),
child: Row(
children: [
Text(
formatDurationTimestamp(Duration(milliseconds: positionMs.round())),
style: TextStyle(fontSize: 12, color: tk.textMuted),
),
const Spacer(),
Text(formatDurationTimestamp(duration), style: TextStyle(fontSize: 12, color: tk.textMuted)),
],
),
),
],
);
},
);
return Focus(
focusNode: widget.focusNode,
descendantsAreFocusable: false,
onKeyEvent: _handleKeyEvent,
onFocusChange: (hasFocus) => setState(() {
_focused = hasFocus;
if (!hasFocus) _resetSeekState();
}),
child: AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: showFocus, borderRadius: tk.radiusLg),
child: bar,
),
);
}
}
+265
View File
@@ -0,0 +1,265 @@
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 '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/media_image_helper.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/bottom_sheet_header.dart';
import '../../widgets/music/equalizer_icon.dart';
import '../../widgets/music/repeat_mode.dart';
import '../../widgets/music/track_row.dart';
import '../../widgets/optimized_media_image.dart';
import '../../widgets/overlay_sheet.dart';
/// Open the play-queue sheet. The caller's screen must have an
/// [OverlaySheetHost] ancestor (all now-playing layouts do) so TV back
/// handling stays centralized in the host.
Future<void> showQueueSheet(BuildContext context) {
return OverlaySheetController.showAdaptive<void>(context, showDragHandle: true, builder: (_) => const QueueSheet());
}
/// Sheet chrome around [QueueList]: header with track count, shuffle/repeat
/// toggles mirroring the service state, and Clear (upcoming) action.
class QueueSheet extends StatelessWidget {
const QueueSheet({super.key});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: .min,
children: [
BottomSheetHeader(
title: t.music.queue,
action: Row(
mainAxisSize: .min,
children: [
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
t.music.trackCount(n: service.queue.length),
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
IconButton(
icon: AppIcon(
Symbols.shuffle_rounded,
fill: 1,
size: 20,
color: service.shuffled ? colorScheme.primary : tk.textMuted,
),
tooltip: t.common.shuffle,
onPressed: service.toggleShuffle,
),
IconButton(
icon: AppIcon(
repeatModeIcon(service.repeatMode),
fill: 1,
size: 20,
color: service.repeatMode == MusicRepeatMode.off ? tk.textMuted : colorScheme.primary,
),
tooltip: repeatModeLabel(service.repeatMode),
onPressed: () => service.setRepeatMode(nextRepeatMode(service.repeatMode)),
),
IconButton(
icon: AppIcon(Symbols.clear_all_rounded, fill: 1, size: 20, color: tk.textMuted),
tooltip: t.music.clearQueue,
onPressed: service.clearUpcoming,
),
],
),
),
const Flexible(child: QueueList()),
],
);
}
}
/// The queue body: pinned current-track row, "Up next" label, and the
/// upcoming tracks as [TrackRow]s. Reused verbatim by the desktop
/// now-playing layout as an inline panel.
///
/// Touch: long-press drag to reorder (delayed drag listener — TrackRow's
/// context menu is disabled here so the gesture arena stays clean), swipe to
/// remove, tap to jump. D-pad: rows are focusable, SELECT jumps, RIGHT
/// reaches a dedicated remove button; reordering is intentionally not
/// offered on TV.
class QueueList extends StatelessWidget {
const QueueList({super.key});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final service = context.watch<MusicPlaybackService>();
final queue = service.queue;
final currentIndex = service.currentIndex;
final current = service.currentTrack;
final upcomingStart = currentIndex + 1;
final upcoming = currentIndex >= 0 && upcomingStart <= queue.length
? queue.sublist(upcomingStart)
: const <MediaItem>[];
// Stable per-item keys that survive reorders; the same track can appear
// more than once, so disambiguate repeats by occurrence.
final seen = <String, int>{};
final itemKeys = [
for (final item in upcoming) '${item.globalKey}#${seen.update(item.globalKey, (v) => v + 1, ifAbsent: () => 0)}',
];
final allowTouchEditing = !PlatformDetector.isTV();
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (current != null)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: _CurrentTrackRow(track: current),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
t.music.upNext,
style: Theme.of(context).textTheme.labelLarge?.copyWith(color: tk.textMuted, fontWeight: .w600),
),
),
Flexible(
child: upcoming.isEmpty
? Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
child: Text(t.messages.noItemsAvailable, style: TextStyle(fontSize: 13, color: tk.textMuted)),
)
: ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
itemCount: upcoming.length,
// onReorderItem already adjusts newIndex for the removal.
onReorderItem: (oldIndex, newIndex) {
if (oldIndex == newIndex) return;
service.reorder(upcomingStart + oldIndex, upcomingStart + newIndex);
},
itemBuilder: (context, index) {
final item = upcoming[index];
final queueIndex = upcomingStart + index;
Widget row = TrackRow(
item: item,
showArtist: true,
isFirst: index == 0,
isLast: index == upcoming.length - 1,
enableContextMenu: false,
trailingIcon: Symbols.close_rounded,
onTrailingTap: () => service.removeAt(queueIndex),
onTap: () => unawaited(service.jumpTo(queueIndex)),
);
if (allowTouchEditing) {
row = ReorderableDelayedDragStartListener(index: index, child: row);
row = Dismissible(
key: ValueKey('dismiss:${itemKeys[index]}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: .centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const AppIcon(Symbols.delete_rounded, fill: 1, color: Colors.white, size: 20),
),
onDismissed: (_) => service.removeAt(queueIndex),
child: row,
);
}
return Padding(
key: ValueKey(itemKeys[index]),
padding: EdgeInsets.only(top: index == 0 ? 0 : tk.groupGap),
child: row,
);
},
),
),
],
);
}
}
/// Pinned "now playing" row at the top of the queue: square art, title +
/// artist, and the shared equalizer indicator.
class _CurrentTrackRow extends StatelessWidget {
final MediaItem track;
const _CurrentTrackRow({required this.track});
@override
Widget build(BuildContext context) {
final tk = tokens(context);
final colorScheme = Theme.of(context).colorScheme;
final isPlaying = context.select<MusicPlaybackService, bool>((s) => s.isPlaying);
final client = context.tryGetMediaClientWithFallback(serverIdOrNull(track.serverId));
final artist = track.trackArtistTitle;
return Material(
color: tk.surface,
borderRadius: BorderRadius.circular(tk.radiusLg),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tk.radiusSm),
child: OptimizedMediaImage(
client: client,
imagePath: track.thumbPath,
imageType: ImageType.square,
width: 44,
height: 44,
fallbackIcon: Symbols.music_note_rounded,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .start,
children: [
Text(
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),
),
],
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(right: 8),
child: EqualizerIcon(animate: isPlaying, color: colorScheme.primary),
),
],
),
),
);
}
}
@@ -9,8 +9,10 @@ import '../../media/media_item.dart';
import '../../media/media_kind.dart'; import '../../media/media_kind.dart';
import '../../media/media_playlist.dart'; import '../../media/media_playlist.dart';
import '../../services/media_list_playback_launcher.dart'; import '../../services/media_list_playback_launcher.dart';
import '../../services/music/music_playback_service.dart';
import '../../services/playlist_items_loader.dart'; import '../../services/playlist_items_loader.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/music_navigation.dart';
import '../../widgets/app_icon.dart'; import '../../widgets/app_icon.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../../focus/dpad_navigator.dart'; import '../../focus/dpad_navigator.dart';
@@ -69,20 +71,60 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
/// filter rules, not direct edits). Jellyfin has no equivalent concept. /// filter rules, not direct edits). Jellyfin has no equivalent concept.
bool get _isReadOnly => widget.playlist.smart; bool get _isReadOnly => widget.playlist.smart;
/// Audio playlists play through the music session (mini-player /
/// now-playing) instead of the video play-queue launcher.
bool get _isAudioPlaylist => widget.playlist.playlistType == 'audio';
MusicPlayContext get _musicPlayContext =>
MusicPlayContext(id: widget.playlist.id, title: widget.playlist.title, kind: MusicPlayContextKind.playlist);
@override
Future<void> playItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: false) : super.playItems();
@override
Future<void> shufflePlayItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: true) : super.shufflePlayItems();
/// Uses the already-loaded items when the playlist is fully paged in;
/// otherwise fetches the full item list (one loader round-trip) so the
/// queue isn't truncated to the first page.
Future<void> _playAudioPlaylist({required bool shuffle, MediaItem? startTrack}) async {
if (items.isEmpty) {
showAppSnackBar(context, emptyMessage);
return;
}
if (!ensureMusicPlaybackAvailable(context)) return;
List<MediaItem> tracks;
if (_isPlaylistFullyLoaded) {
tracks = items;
} else {
try {
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
} catch (e) {
appLogger.w('Failed to fetch full audio playlist ${widget.playlist.id}', error: e);
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
return;
}
if (!mounted) return;
}
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
}
@override @override
List<FocusableAction> getAppBarActions() { List<FocusableAction> getAppBarActions() {
final isVideoPlaylist = widget.playlist.playlistType == 'video'; // Video AND audio playlists download (tracks queue through the same list
// pipeline); photo/mixed playlists keep the affordance hidden.
final isDownloadablePlaylist = widget.playlist.playlistType == 'video' || _isAudioPlaylist;
final ruleKey = _playlistSyncRuleKey(); final ruleKey = _playlistSyncRuleKey();
// Select the specific bool we care about so unrelated DownloadProvider // Select the specific bool we care about so unrelated DownloadProvider
// ticks (e.g. active download progress) don't rebuild the app bar. // ticks (e.g. active download progress) don't rebuild the app bar.
final hasRule = isVideoPlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey)); final hasRule = isDownloadablePlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
return [ return [
if (items.isNotEmpty) ...[ if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
], ],
if (!PlatformDetector.isAppleTV() && isVideoPlaylist && (items.isNotEmpty || hasRule)) if (!PlatformDetector.isAppleTV() && isDownloadablePlaylist && (items.isNotEmpty || hasRule))
FocusableAction( FocusableAction(
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
@@ -501,6 +543,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
if (items.isEmpty || index < 0 || index >= items.length) return; if (items.isEmpty || index < 0 || index >= items.length) return;
final selectedItem = items[index]; final selectedItem = items[index];
if (_isAudioPlaylist) {
await _playAudioPlaylist(shuffle: false, startTrack: selectedItem);
return;
}
final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist); final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist);
await launcher.launchFromCollectionOrPlaylist( await launcher.launchFromCollectionOrPlaylist(
item: widget.playlist, item: widget.playlist,
@@ -246,6 +246,10 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
return '$year · $edition'; return '$year · $edition';
} }
return year ?? t.discover.movie; return year ?? t.discover.movie;
} else if (kind == MediaKind.track) {
// Music: "Artist · Album" (either half may be missing).
final parts = [item.trackArtistTitle, item.albumTitle].nonNulls.where((part) => part.isNotEmpty).toList();
if (parts.isNotEmpty) return parts.join(' · ');
} }
// Default to type // Default to type
+77 -5
View File
@@ -3,21 +3,91 @@ import 'package:provider/provider.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../navigation/profile_navigation_scope.dart';
import '../screens/music/album_detail_screen.dart'; import '../screens/music/album_detail_screen.dart';
import '../screens/music/artist_detail_screen.dart'; import '../screens/music/artist_detail_screen.dart';
import '../screens/music/now_playing_screen.dart';
import '../services/device_performance.dart';
import '../services/music/music_playback_service.dart'; import '../services/music/music_playback_service.dart';
import '../theme/mono_motion.dart';
import '../theme/mono_tokens.dart';
import 'app_logger.dart'; import 'app_logger.dart';
import 'platform_detector.dart';
import 'provider_extensions.dart'; import 'provider_extensions.dart';
import 'snackbar_helper.dart'; import 'snackbar_helper.dart';
/// Push the artist detail screen for [artist] on the nearest navigator. /// Route name of the now-playing screen — the mini-player's route observer
Future<void> navigateToArtist(BuildContext context, MediaItem artist) async { /// suppresses itself while this (or the video player) is in the stack.
await Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistDetailScreen(artist: artist))); const String kNowPlayingRouteName = '/now_playing';
/// Content routes belong on the profile-session navigator. For contexts
/// inside it this is exactly `Navigator.of(context)`; the mini-player overlay
/// sits *above* that navigator (its nearest navigator is the root one), so it
/// resolves the profile navigator through [ProfileNavigationScope] instead.
NavigatorState _contentNavigatorOf(BuildContext context) {
return ProfileNavigationScope.maybeOf(context)?.navigatorKey.currentState ?? Navigator.of(context);
} }
/// Push the album detail screen for [album] on the nearest navigator. /// Push the artist detail screen for [artist] on the profile navigator.
Future<void> navigateToArtist(BuildContext context, MediaItem artist) async {
await _contentNavigatorOf(context).push(MaterialPageRoute(builder: (context) => ArtistDetailScreen(artist: artist)));
}
/// Push the album detail screen for [album] on the profile navigator.
Future<void> navigateToAlbum(BuildContext context, MediaItem album) async { Future<void> navigateToAlbum(BuildContext context, MediaItem album) async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => AlbumDetailScreen(album: album))); await _contentNavigatorOf(context).push(MaterialPageRoute(builder: (context) => AlbumDetailScreen(album: album)));
}
/// Push the now-playing screen (slide-up + fade) on the profile navigator.
/// No-op when it is already on top (e.g. TV auto-push while open). Popping
/// it never touches playback — audio continues under the mini-player.
Future<void> openNowPlaying(BuildContext context) async {
final navigator = _contentNavigatorOf(context);
if (_isRouteOnTop(navigator, kNowPlayingRouteName)) return;
final duration = DevicePerformance.reducedDuration(tokens(context).expressive);
await navigator.push(
PageRouteBuilder<void>(
settings: const RouteSettings(name: kNowPlayingRouteName),
transitionDuration: duration,
reverseTransitionDuration: duration,
pageBuilder: (context, animation, secondaryAnimation) => const NowPlayingScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: MonoMotion.emphasized,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 0.15), end: Offset.zero).animate(curved),
child: child,
),
);
},
),
);
}
bool _isRouteOnTop(NavigatorState navigator, String name) {
var onTop = false;
navigator.popUntil((route) {
if (route.isCurrent) onTop = route.settings.name == name;
return true; // inspect only — never pops
});
return onTop;
}
/// TV has no persistent mini-player, so starting playback lands the user on
/// the now-playing screen directly.
void _autoOpenNowPlayingOnTv(BuildContext context) {
if (!PlatformDetector.isTV() || !context.mounted) return;
// A failed start (error surfaced on the service's errors stream) leaves no
// current track — nothing to show.
if (context.read<MusicPlaybackService>().currentTrack == null) return;
openNowPlaying(context).catchError((Object e) {
appLogger.w('Failed to auto-open now playing', error: e);
});
} }
/// True when a real music playback engine is bound. On the stub this shows /// True when a real music playback engine is bound. On the stub this shows
@@ -45,6 +115,7 @@ Future<void> playTracks(
playContext: playContext, playContext: playContext,
shuffle: shuffle, shuffle: shuffle,
); );
if (context.mounted) _autoOpenNowPlayingOnTv(context);
} }
/// Play [track] within its album queue: fetch the album's tracks and start /// Play [track] within its album queue: fetch the album's tracks and start
@@ -88,4 +159,5 @@ Future<void> playTrackWithAlbumContext(BuildContext context, MediaItem track) as
Future<void> playInstantMix(BuildContext context, MediaItem seed) async { Future<void> playInstantMix(BuildContext context, MediaItem seed) async {
if (!ensureMusicPlaybackAvailable(context)) return; if (!ensureMusicPlaybackAvailable(context)) return;
await context.read<MusicPlaybackService>().playInstantMix(seed); await context.read<MusicPlaybackService>().playInstantMix(seed);
if (context.mounted) _autoOpenNowPlayingOnTv(context);
} }
+41 -7
View File
@@ -82,6 +82,16 @@ bool isAdminActionAllowedForMediaItem({
/// A reusable wrapper widget that adds a context menu (long press / right click) /// A reusable wrapper widget that adds a context menu (long press / right click)
/// to any media item with appropriate actions based on the item type. /// to any media item with appropriate actions based on the item type.
/// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the
/// now-playing screen's Sleep timer). Selection runs [onSelected].
class MediaMenuExtraEntry {
final IconData icon;
final String label;
final VoidCallback onSelected;
const MediaMenuExtraEntry({required this.icon, required this.label, required this.onSelected});
}
class MediaContextMenu extends StatefulWidget { class MediaContextMenu extends StatefulWidget {
/// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because /// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because
/// Dart has no nominal union type — guarded at runtime via the /// Dart has no nominal union type — guarded at runtime via the
@@ -102,6 +112,9 @@ class MediaContextMenu extends StatefulWidget {
final bool isInContinueWatching; final bool isInContinueWatching;
final String? collectionId; // The collection ID if displaying within a collection final String? collectionId; // The collection ID if displaying within a collection
/// Extra entries appended after the standard actions.
final List<MediaMenuExtraEntry> extraEntries;
const MediaContextMenu({ const MediaContextMenu({
super.key, super.key,
required this.item, required this.item,
@@ -113,6 +126,7 @@ class MediaContextMenu extends StatefulWidget {
required this.child, required this.child,
this.isInContinueWatching = false, this.isInContinueWatching = false,
this.collectionId, this.collectionId,
this.extraEntries = const [],
}); });
@override @override
@@ -253,10 +267,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay)); menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay));
// Download + sync-rule management. Video playlists and any collection // Download + sync-rule management. Video and audio playlists and any
// qualify — collections can contain movies, episodes, and shows. // collection qualify — collections can contain movies, episodes,
final isVideoPlaylist = isPlaylist && playlist.playlistType == 'video'; // shows, albums, and artists; audio playlists queue their tracks.
if ((isVideoPlaylist || isCollection) && !PlatformDetector.isAppleTV()) { final isDownloadablePlaylist =
isPlaylist && (playlist.playlistType == 'video' || playlist.playlistType == 'audio');
if ((isDownloadablePlaylist || isCollection) && !PlatformDetector.isAppleTV()) {
final hasRule = Provider.of<DownloadProvider>(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)); final hasRule = Provider.of<DownloadProvider>(context, listen: false).hasSyncRule(_itemSyncRuleKey(context));
if (hasRule) { if (hasRule) {
menuActions.add( menuActions.add(
@@ -473,13 +489,17 @@ class MediaContextMenuState extends State<MediaContextMenu> {
); );
} }
// Download options (for episodes, movies, shows, and seasons). // Download options (for episodes, movies, shows, seasons, albums, and
// Apple TV has no user-accessible file storage — skip entirely. // tracks — not artists, whose full discography is too large for a
// one-tap download). Apple TV has no user-accessible file storage —
// skip entirely.
if (!PlatformDetector.isAppleTV() && if (!PlatformDetector.isAppleTV() &&
(mediaKind == MediaKind.episode || (mediaKind == MediaKind.episode ||
mediaKind == MediaKind.movie || mediaKind == MediaKind.movie ||
mediaKind == MediaKind.show || mediaKind == MediaKind.show ||
mediaKind == MediaKind.season)) { mediaKind == MediaKind.season ||
mediaKind == MediaKind.album ||
mediaKind == MediaKind.track)) {
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false); final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
final globalKey = mediaItem.globalKey; final globalKey = mediaItem.globalKey;
final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context)); final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context));
@@ -549,6 +569,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
} }
} }
for (var i = 0; i < widget.extraEntries.length; i++) {
final entry = widget.extraEntries[i];
menuActions.add(_MenuAction(value: 'extra_$i', icon: entry.icon, label: entry.label));
}
String? selected; String? selected;
final openedFromKeyboard = _openedFromKeyboard; final openedFromKeyboard = _openedFromKeyboard;
@@ -585,6 +610,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
try { try {
if (!context.mounted) return; if (!context.mounted) return;
// Caller-supplied extra entries dispatch straight to their callback.
if (selected != null && selected.startsWith('extra_')) {
final index = int.tryParse(selected.substring('extra_'.length));
if (index != null && index >= 0 && index < widget.extraEntries.length) {
widget.extraEntries[index].onSelected();
}
return;
}
switch (selected) { switch (selected) {
case 'play_from_beginning': case 'play_from_beginning':
didNavigate = true; didNavigate = true;
+109
View File
@@ -0,0 +1,109 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../services/device_performance.dart';
/// Small 3-bar "now playing" indicator. Bars animate while [animate] is true;
/// on the reduced visual-effects tier they render static regardless (each
/// animation frame re-rasterizes the host row on weak TV GPUs).
///
/// Shared between [TrackRow]'s leading column, the queue sheet's current-track
/// header, and the side navigation rail's Now Playing item.
class EqualizerIcon extends StatefulWidget {
final bool animate;
final Color color;
const EqualizerIcon({super.key, required this.animate, required this.color});
@override
State<EqualizerIcon> createState() => _EqualizerIconState();
}
class _EqualizerIconState extends State<EqualizerIcon> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
bool get _shouldAnimate => widget.animate && !DevicePerformance.isReduced;
@override
void initState() {
super.initState();
_syncAnimation();
}
@override
void didUpdateWidget(EqualizerIcon oldWidget) {
super.didUpdateWidget(oldWidget);
_syncAnimation();
}
void _syncAnimation() {
if (_shouldAnimate) {
if (!_controller.isAnimating) _controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 16,
height: 14,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
painter: _EqualizerPainter(t: _controller.value, color: widget.color, animate: _shouldAnimate),
),
),
);
}
}
class _EqualizerPainter extends CustomPainter {
final double t;
final Color color;
final bool animate;
/// Static bar heights (fraction of full height) for the paused/reduced look.
static const List<double> _staticHeights = [0.55, 0.9, 0.4];
/// Per-bar phase offsets so the animated bars move out of step.
static const List<double> _phases = [0.0, 0.35, 0.7];
const _EqualizerPainter({required this.t, required this.color, required this.animate});
@override
void paint(Canvas canvas, Size size) {
const barCount = 3;
const gap = 2.5;
final barWidth = (size.width - gap * (barCount - 1)) / barCount;
final paint = Paint()..color = color;
for (var i = 0; i < barCount; i++) {
final fraction = animate ? 0.3 + 0.7 * (0.5 + 0.5 * math.sin(2 * math.pi * (t + _phases[i]))) : _staticHeights[i];
final barHeight = size.height * fraction;
final left = i * (barWidth + gap);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, size.height - barHeight, barWidth, barHeight),
const Radius.circular(1.5),
),
paint,
);
}
}
@override
bool shouldRepaint(_EqualizerPainter oldDelegate) =>
oldDelegate.t != t || oldDelegate.color != color || oldDelegate.animate != animate;
}
+314
View File
@@ -0,0 +1,314 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/focus_theme.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../media/lyrics.dart';
import '../../screens/libraries/state_messages.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart';
/// Lyrics pane for the now-playing screen (swapped in over the artwork).
///
/// Synced lyrics highlight the active line from the service's
/// [MusicPlaybackService.positionStream] (binary search over line offsets)
/// and auto-center it; auto-centering pauses for a few seconds after the
/// user scrolls manually. Tapping a synced line seeks to it.
///
/// D-pad model (when [focusNode] is provided): the pane is ONE focusable
/// region. UP/DOWN move a focused-line highlight (synced) or scroll by lines
/// (unsynced), SELECT seeks to the focused line, BACK — or DOWN past the last
/// line — calls [onExit] so the host returns focus to the transport row.
class LyricsView extends StatefulWidget {
/// Resolved lazily by the host (which caches per track).
final Future<Lyrics?> lyricsFuture;
/// Focus node for the pane region — omit on touch-only layouts.
final FocusNode? focusNode;
/// Called on BACK / DOWN-out so the host can move focus back to transport.
final VoidCallback? onExit;
const LyricsView({super.key, required this.lyricsFuture, this.focusNode, this.onExit});
@override
State<LyricsView> createState() => _LyricsViewState();
}
class _LyricsViewState extends State<LyricsView> {
static const Duration _manualScrollHold = Duration(seconds: 3);
static const double _unsyncedScrollStep = 64;
final ScrollController _scroll = ScrollController();
Lyrics? _lyrics;
bool _loading = true;
List<GlobalKey> _lineKeys = const [];
StreamSubscription<Duration>? _positionSub;
int _activeIndex = -1;
DateTime? _manualScrollAt;
bool _paneFocused = false;
int _focusedLine = 0;
@override
void initState() {
super.initState();
_resolve(widget.lyricsFuture);
}
@override
void didUpdateWidget(LyricsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.lyricsFuture != oldWidget.lyricsFuture) {
setState(() {
_lyrics = null;
_loading = true;
_activeIndex = -1;
_focusedLine = 0;
_lineKeys = const [];
});
_resolve(widget.lyricsFuture);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_positionSub ??= context.read<MusicPlaybackService>().positionStream.listen(_onPosition);
}
@override
void dispose() {
_positionSub?.cancel();
_scroll.dispose();
super.dispose();
}
void _resolve(Future<Lyrics?> future) {
future
.then((lyrics) {
if (!mounted || widget.lyricsFuture != future) return;
setState(() {
_lyrics = lyrics;
_loading = false;
_lineKeys = List.generate(lyrics?.lines.length ?? 0, (_) => GlobalKey());
});
// Jump straight to the current line on open.
_onPosition(context.read<MusicPlaybackService>().position, animate: false);
})
.catchError((Object _) {
if (!mounted || widget.lyricsFuture != future) return;
setState(() {
_lyrics = null;
_loading = false;
});
});
}
/// Last line whose startMs <= position (binary search; lines without a
/// start offset never become active).
int _activeIndexFor(Duration position) {
final lines = _lyrics?.lines;
if (lines == null || lines.isEmpty) return -1;
final ms = position.inMilliseconds;
var lo = 0, hi = lines.length - 1, best = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final start = lines[mid].startMs;
if (start == null) {
// Rare unsynced holes: scan linearly around them.
break;
}
if (start <= ms) {
best = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
if (lo <= hi) {
// Fallback linear pass for mixed-sync content.
best = -1;
for (var i = 0; i < lines.length; i++) {
final start = lines[i].startMs;
if (start != null && start <= ms) best = i;
}
}
return best;
}
void _onPosition(Duration position, {bool animate = true}) {
final lyrics = _lyrics;
if (lyrics == null || !lyrics.synced) return;
final index = _activeIndexFor(position);
if (index == _activeIndex) return;
setState(() {
_activeIndex = index;
if (!_paneFocused) _focusedLine = index < 0 ? 0 : index;
});
if (index >= 0) _revealLine(index, animate: animate);
}
bool get _autoScrollSuppressed {
final at = _manualScrollAt;
return at != null && DateTime.now().difference(at) < _manualScrollHold;
}
void _revealLine(int index, {bool animate = true, bool force = false}) {
if (!force && _autoScrollSuppressed) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || index < 0 || index >= _lineKeys.length) return;
final lineContext = _lineKeys[index].currentContext;
if (lineContext == null) return;
Scrollable.ensureVisible(
lineContext,
alignment: 0.4,
duration: animate ? const Duration(milliseconds: 300) : Duration.zero,
curve: Curves.easeOutCubic,
);
});
}
bool _handleScrollNotification(ScrollNotification notification) {
final isUserDrag =
(notification is ScrollStartNotification && notification.dragDetails != null) ||
(notification is ScrollUpdateNotification && notification.dragDetails != null);
if (isUserDrag) _manualScrollAt = DateTime.now();
return false;
}
void _seekTo(LyricLine line) {
final startMs = line.startMs;
if (startMs == null) return;
unawaited(context.read<MusicPlaybackService>().seek(Duration(milliseconds: startMs)));
}
// -------------------------------------------------------------------
// D-pad
// -------------------------------------------------------------------
void _moveFocusedLine(int delta) {
final lyrics = _lyrics;
if (lyrics == null || lyrics.lines.isEmpty) return;
if (!lyrics.synced) {
// Unsynced: scroll by lines instead of tracking a highlight.
if (!_scroll.hasClients) return;
final target = (_scroll.offset + delta * _unsyncedScrollStep).clamp(
_scroll.position.minScrollExtent,
_scroll.position.maxScrollExtent,
);
unawaited(_scroll.animateTo(target, duration: const Duration(milliseconds: 150), curve: Curves.easeOutCubic));
return;
}
final next = _focusedLine + delta;
if (next >= lyrics.lines.length) {
widget.onExit?.call();
return;
}
if (next < 0) return;
setState(() => _focusedLine = next);
_revealLine(next, force: true);
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final onExit = widget.onExit;
if (onExit != null) {
final backResult = handleBackKeyAction(event, onExit);
if (backResult != KeyEventResult.ignored) return backResult;
}
return dpadKeyHandler(
onUp: () => _moveFocusedLine(-1),
onDown: () => _moveFocusedLine(1),
onSelect: () {
final lyrics = _lyrics;
if (lyrics == null || !lyrics.synced) return;
if (_focusedLine >= 0 && _focusedLine < lyrics.lines.length) _seekTo(lyrics.lines[_focusedLine]);
},
trapHorizontalEdges: true,
)(node, event);
}
// -------------------------------------------------------------------
// Build
// -------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final tk = tokens(context);
if (_loading) {
return Center(child: CircularProgressIndicator(color: tk.textMuted));
}
final lyrics = _lyrics;
if (lyrics == null || lyrics.isEmpty) {
return StateMessageWidget(icon: Symbols.lyrics_rounded, message: t.music.noLyrics, iconSize: 48);
}
final showFocus = _paneFocused && InputModeTracker.isKeyboardMode(context);
final Widget list = NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: SingleChildScrollView(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
child: Column(
crossAxisAlignment: .stretch,
children: [
for (var i = 0; i < lyrics.lines.length; i++) _buildLine(context, lyrics, i, showFocus: showFocus),
],
),
),
);
final focusNode = widget.focusNode;
if (focusNode == null) return list;
return Focus(
focusNode: focusNode,
onKeyEvent: _handleKeyEvent,
onFocusChange: (hasFocus) => setState(() {
_paneFocused = hasFocus;
if (hasFocus && _activeIndex >= 0) _focusedLine = _activeIndex;
}),
child: list,
);
}
Widget _buildLine(BuildContext context, Lyrics lyrics, int index, {required bool showFocus}) {
final tk = tokens(context);
final line = lyrics.lines[index];
final isActive = lyrics.synced && index == _activeIndex;
final isFocusedLine = showFocus && lyrics.synced && index == _focusedLine;
final text = Text(
line.text.isEmpty ? '' : line.text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
height: 1.4,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
color: isActive || !lyrics.synced ? tk.text : tk.textMuted,
),
);
return KeyedSubtree(
key: _lineKeys[index],
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: lyrics.synced && line.startMs != null ? () => _seekTo(line) : null,
child: AnimatedContainer(
duration: tk.fast,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// Focus renders as a text-based background fill (mono convention).
decoration: FocusTheme.textFillFocusDecoration(context, isFocused: isFocusedLine, borderRadius: tk.radiusSm),
child: text,
),
),
);
}
}
+405
View File
@@ -0,0 +1,405 @@
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)),
),
);
},
);
}
}
+5 -2
View File
@@ -7,13 +7,15 @@ import '../app_icon.dart';
/// Standard action list for the music detail screens (album/artist): /// Standard action list for the music detail screens (album/artist):
/// a labeled Play pill, a shuffle icon, an optional Instant Mix icon (pass /// a labeled Play pill, a shuffle icon, an optional Instant Mix icon (pass
/// null when the server lacks the capability), and an optional [trailing] /// null when the server lacks the capability), an optional [download]
/// action (the album overflow ⋮). Render inside a [FocusableActionBar] — /// action (the album download button), and an optional [trailing] action
/// (the album overflow ⋮). Render inside a [FocusableActionBar] —
/// icon actions get the bar's default focus-background treatment. /// icon actions get the bar's default focus-background treatment.
List<FocusableAction> buildMusicActions({ List<FocusableAction> buildMusicActions({
required VoidCallback onPlay, required VoidCallback onPlay,
required VoidCallback onShuffle, required VoidCallback onShuffle,
VoidCallback? onInstantMix, VoidCallback? onInstantMix,
FocusableAction? download,
FocusableAction? trailing, FocusableAction? trailing,
}) { }) {
return [ return [
@@ -35,6 +37,7 @@ List<FocusableAction> buildMusicActions({
tooltip: t.music.instantMix, tooltip: t.music.instantMix,
onPressed: onInstantMix, onPressed: onInstantMix,
), ),
?download,
?trailing, ?trailing,
]; ];
} }
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/widgets.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart';
import '../../services/music/music_playback_service.dart';
/// Shared repeat-mode presentation for the now-playing transport row and the
/// queue sheet header: icon, accessibility label, and the off→all→one cycle.
IconData repeatModeIcon(MusicRepeatMode mode) => switch (mode) {
MusicRepeatMode.off => Symbols.repeat_rounded,
MusicRepeatMode.all => Symbols.repeat_on_rounded,
MusicRepeatMode.one => Symbols.repeat_one_on_rounded,
};
String repeatModeLabel(MusicRepeatMode mode) => switch (mode) {
MusicRepeatMode.off => t.music.repeat,
MusicRepeatMode.all => t.music.repeatAll,
MusicRepeatMode.one => t.music.repeatOne,
};
MusicRepeatMode nextRepeatMode(MusicRepeatMode mode) => switch (mode) {
MusicRepeatMode.off => MusicRepeatMode.all,
MusicRepeatMode.all => MusicRepeatMode.one,
MusicRepeatMode.one => MusicRepeatMode.off,
};
+155 -176
View File
@@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -9,12 +7,15 @@ import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart'; import '../../focus/key_event_utils.dart';
import '../../media/media_item.dart'; import '../../media/media_item.dart';
import '../../mixins/context_menu_tap_mixin.dart'; import '../../mixins/context_menu_tap_mixin.dart';
import '../../services/device_performance.dart'; import '../../models/download_models.dart';
import '../../providers/download_provider.dart';
import '../../services/music/music_playback_service.dart'; import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart'; import '../../theme/mono_tokens.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../app_icon.dart'; import '../app_icon.dart';
import '../download_status_icon.dart';
import '../media_context_menu.dart'; import '../media_context_menu.dart';
import 'equalizer_icon.dart';
/// List row for a music track: /// List row for a music track:
/// `[track # | equalizer] [title + optional artist] [duration] [⋮]`. /// `[track # | equalizer] [title + optional artist] [duration] [⋮]`.
@@ -56,6 +57,24 @@ class TrackRow extends StatefulWidget {
final ValueChanged<bool>? onFocusChange; final ValueChanged<bool>? onFocusChange;
/// Replace the trailing ⋮ context-menu button with a dedicated action
/// (e.g. the queue sheet's remove button). SELECT on the trailing column
/// runs [onTrailingTap] instead of opening the context menu.
final IconData? trailingIcon;
final VoidCallback? onTrailingTap;
/// Disable the long-press / right-click context menu — hosts that own the
/// long-press gesture themselves (the queue sheet's drag-to-reorder) must
/// opt out so the recognizers don't fight in the gesture arena.
final bool enableContextMenu;
/// Show a compact download-status indicator (muted [DownloadStatusIcon])
/// between the duration and the trailing ⋮ when the track has a download
/// record. Off by default — surfaces without download affordances (queue
/// sheet) keep the row untouched. Renders nothing when no
/// [DownloadProvider] is in scope.
final bool showDownloadStatus;
const TrackRow({ const TrackRow({
super.key, super.key,
required this.item, required this.item,
@@ -68,6 +87,10 @@ class TrackRow extends StatefulWidget {
this.onNavigateUp, this.onNavigateUp,
this.onBack, this.onBack,
this.onFocusChange, this.onFocusChange,
this.trailingIcon,
this.onTrailingTap,
this.enableContextMenu = true,
this.showDownloadStatus = false,
}); });
@override @override
@@ -111,6 +134,8 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
void _activateFocusedColumn() { void _activateFocusedColumn() {
if (_focusedColumn == 0) { if (_focusedColumn == 0) {
widget.onTap?.call(); widget.onTap?.call();
} else if (widget.onTrailingTap != null) {
widget.onTrailingTap!();
} else { } else {
// Keyboard/gamepad activation — the menu centers on the row. // Keyboard/gamepad activation — the menu centers on the row.
showContextMenu(); showContextMenu();
@@ -163,202 +188,156 @@ class _TrackRowState extends State<TrackRow> with ContextMenuTapMixin<TrackRow>,
final subtitle = _subtitle; final subtitle = _subtitle;
final durationMs = widget.item.durationMs; final durationMs = widget.item.durationMs;
final withContextMenu = widget.enableContextMenu;
return MediaContextMenu( final row = Focus(
key: contextMenuKey, focusNode: effectiveFocusNode,
item: widget.item, descendantsAreFocusable: false,
onRefresh: widget.onRefresh, onKeyEvent: _handleKeyEvent,
onTap: widget.onTap, onFocusChange: _handleFocusChange,
child: Focus( child: Material(
focusNode: effectiveFocusNode, color: tk.surface,
descendantsAreFocusable: false, clipBehavior: Clip.antiAlias,
onKeyEvent: _handleKeyEvent, shape: RoundedRectangleBorder(borderRadius: radii),
onFocusChange: _handleFocusChange, child: InkWell(
child: Material( mouseCursor: SystemMouseCursors.click,
color: tk.surface, onTap: widget.onTap,
clipBehavior: Clip.antiAlias, onTapDown: withContextMenu ? storeTapPosition : null,
shape: RoundedRectangleBorder(borderRadius: radii), onLongPress: withContextMenu ? showContextMenuFromTap : null,
child: InkWell( onSecondaryTapDown: withContextMenu ? storeTapPosition : null,
mouseCursor: SystemMouseCursors.click, onSecondaryTap: withContextMenu ? showContextMenuFromTap : null,
onTap: widget.onTap, child: Container(
onTapDown: storeTapPosition, height: TrackRow.height,
onLongPress: showContextMenuFromTap, // Text-based fill (mono theme focusColor convention) — the
onSecondaryTapDown: storeTapPosition, // white-based FocusTheme fill is invisible on the light row
onSecondaryTap: showContextMenuFromTap, // surface.
child: Container( decoration: BoxDecoration(
height: TrackRow.height, borderRadius: radii,
// Text-based fill (mono theme focusColor convention) — the color: showFocus && _focusedColumn == 0 ? tk.text.withValues(alpha: 0.12) : Colors.transparent,
// white-based FocusTheme fill is invisible on the light row ),
// surface. padding: const EdgeInsets.only(left: 12, right: 4),
decoration: BoxDecoration( child: Row(
borderRadius: radii, children: [
color: showFocus && _focusedColumn == 0 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, SizedBox(
), width: 32,
padding: const EdgeInsets.only(left: 12, right: 4), child: Center(
child: Row( child: isCurrent
children: [ ? EqualizerIcon(animate: serviceIsPlaying, color: colorScheme.primary)
SizedBox( : Text('${widget.item.trackNumber ?? ''}', style: TextStyle(fontSize: 13, color: tk.textMuted)),
width: 32,
child: Center(
child: isCurrent
? _EqualizerIcon(animate: serviceIsPlaying, color: colorScheme.primary)
: Text(
'${widget.item.trackNumber ?? ''}',
style: TextStyle(fontSize: 13, color: tk.textMuted),
),
),
), ),
const SizedBox(width: 8), ),
Expanded( const SizedBox(width: 8),
child: Column( Expanded(
mainAxisAlignment: .center, child: Column(
crossAxisAlignment: .start, mainAxisAlignment: .center,
children: [ crossAxisAlignment: .start,
children: [
Text(
widget.item.title ?? '',
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
color: isCurrent ? tk.text : null,
),
),
if (subtitle != null)
Text( Text(
widget.item.title ?? '', subtitle,
maxLines: 1, maxLines: 1,
overflow: .ellipsis, overflow: .ellipsis,
style: TextStyle( style: TextStyle(fontSize: 12, color: tk.textMuted),
fontSize: 14,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
color: isCurrent ? tk.text : null,
),
), ),
if (subtitle != null) ],
Text(
subtitle,
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(fontSize: 12, color: tk.textMuted),
),
],
),
), ),
const SizedBox(width: 8), ),
if (durationMs != null) const SizedBox(width: 8),
Text( if (durationMs != null)
formatDurationTimestamp(Duration(milliseconds: durationMs)), Text(
style: TextStyle(fontSize: 13, color: tk.textMuted), formatDurationTimestamp(Duration(milliseconds: durationMs)),
), style: TextStyle(fontSize: 13, color: tk.textMuted),
Container( ),
decoration: BoxDecoration( if (widget.showDownloadStatus) _TrackDownloadStatus(item: widget.item),
borderRadius: BorderRadius.circular(20), Container(
color: showFocus && _focusedColumn == 1 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, decoration: BoxDecoration(
), borderRadius: BorderRadius.circular(20),
child: Builder( color: showFocus && _focusedColumn == 1 ? tk.text.withValues(alpha: 0.12) : Colors.transparent,
builder: (buttonContext) => IconButton( ),
icon: AppIcon(Symbols.more_vert_rounded, fill: 1, size: 20, color: tk.textMuted), child: Builder(
onPressed: () => _showMenuAt(buttonContext), builder: (buttonContext) => IconButton(
icon: AppIcon(
widget.trailingIcon ?? Symbols.more_vert_rounded,
fill: 1,
size: 20,
color: tk.textMuted,
), ),
onPressed: widget.onTrailingTap ?? () => _showMenuAt(buttonContext),
), ),
), ),
], ),
), ],
), ),
), ),
), ),
), ),
); );
}
}
/// Small 3-bar "now playing" indicator. Bars animate while [animate] is true; if (!withContextMenu) return row;
/// on the reduced visual-effects tier they render static regardless (each return MediaContextMenu(
/// animation frame re-rasterizes the row on weak TV GPUs). key: contextMenuKey,
class _EqualizerIcon extends StatefulWidget { item: widget.item,
final bool animate; onRefresh: widget.onRefresh,
final Color color; onTap: widget.onTap,
child: row,
const _EqualizerIcon({required this.animate, required this.color});
@override
State<_EqualizerIcon> createState() => _EqualizerIconState();
}
class _EqualizerIconState extends State<_EqualizerIcon> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
bool get _shouldAnimate => widget.animate && !DevicePerformance.isReduced;
@override
void initState() {
super.initState();
_syncAnimation();
}
@override
void didUpdateWidget(_EqualizerIcon oldWidget) {
super.didUpdateWidget(oldWidget);
_syncAnimation();
}
void _syncAnimation() {
if (_shouldAnimate) {
if (!_controller.isAnimating) _controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 16,
height: 14,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
painter: _EqualizerPainter(t: _controller.value, color: widget.color, animate: _shouldAnimate),
),
),
); );
} }
} }
class _EqualizerPainter extends CustomPainter { /// Compact per-track download indicator, mirroring the episode-card pattern:
final double t; /// a queueing spinner while the queue is being built, then a muted
final Color color; /// [DownloadStatusIcon] for any live download record. A [Selector] slice
final bool animate; /// keeps unrelated provider ticks from rebuilding the row.
class _TrackDownloadStatus extends StatelessWidget {
final MediaItem item;
/// Static bar heights (fraction of full height) for the paused/reduced look. const _TrackDownloadStatus({required this.item});
static const List<double> _staticHeights = [0.55, 0.9, 0.4];
/// Per-bar phase offsets so the animated bars move out of step.
static const List<double> _phases = [0.0, 0.35, 0.7];
const _EqualizerPainter({required this.t, required this.color, required this.animate});
@override @override
void paint(Canvas canvas, Size size) { Widget build(BuildContext context) {
const barCount = 3; // Graceful no-op when no DownloadProvider is in scope (tests, playback
const gap = 2.5; // queue contexts) or the item can't have a download record.
final barWidth = (size.width - gap * (barCount - 1)) / barCount; if (item.serverId == null || context.read<DownloadProvider?>() == null) {
final paint = Paint()..color = color; return const SizedBox.shrink();
for (var i = 0; i < barCount; i++) {
final fraction = animate ? 0.3 + 0.7 * (0.5 + 0.5 * math.sin(2 * math.pi * (t + _phases[i]))) : _staticHeights[i];
final barHeight = size.height * fraction;
final left = i * (barWidth + gap);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(left, size.height - barHeight, barWidth, barHeight),
const Radius.circular(1.5),
),
paint,
);
} }
}
@override return Selector<DownloadProvider, (DownloadStatus?, double?, bool)>(
bool shouldRepaint(_EqualizerPainter oldDelegate) => selector: (_, p) {
oldDelegate.t != t || oldDelegate.color != color || oldDelegate.animate != animate; final progress = p.getProgress(item.globalKey);
return (progress?.status, progress?.progressPercent, p.isQueueing(item.globalKey));
},
builder: (context, slice, _) {
final (status, progressPercent, isQueueing) = slice;
final mutedBase = tokens(context).textMuted;
final Widget? icon;
if (isQueueing) {
icon = DownloadQueueingSpinner(size: 12, color: mutedBase);
} else if (status != null) {
icon = DownloadStatusIcon(
status: status,
size: status == DownloadStatus.downloading ? 14 : 12,
variant: DownloadStatusIconVariant.muted,
mutedBase: mutedBase,
progress: progressPercent,
);
} else {
icon = null;
}
if (icon == null) return const SizedBox.shrink();
return Padding(padding: const EdgeInsets.only(left: 8), child: icon);
},
);
}
} }
+76 -6
View File
@@ -10,15 +10,19 @@ import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/focus_memory_tracker.dart'; import '../focus/focus_memory_tracker.dart';
import '../media/media_item.dart';
import '../media/media_library.dart'; import '../media/media_library.dart';
import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/mounted_set_state_mixin.dart';
import '../navigation/navigation_tabs.dart'; import '../navigation/navigation_tabs.dart';
import '../providers/hidden_libraries_provider.dart'; import '../providers/hidden_libraries_provider.dart';
import '../providers/libraries_provider.dart'; import '../providers/libraries_provider.dart';
import '../services/music/music_playback_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/music_navigation.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../utils/scroll_utils.dart'; import '../utils/scroll_utils.dart';
import '../utils/library_grouping.dart'; import '../utils/library_grouping.dart';
import 'music/equalizer_icon.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../services/fullscreen_state_manager.dart'; import '../services/fullscreen_state_manager.dart';
import '../theme/mono_tokens.dart'; import '../theme/mono_tokens.dart';
@@ -51,6 +55,10 @@ final class _LibraryItemRow extends _LibraryNavRow {
class NavigationRailItem extends StatelessWidget { class NavigationRailItem extends StatelessWidget {
final IconData icon; final IconData icon;
final IconData? selectedIcon; final IconData? selectedIcon;
/// Custom leading widget rendered instead of the [icon] (e.g. the Now
/// Playing item's equalizer). Should be at most [iconSize] tall/wide.
final Widget? iconWidget;
final Widget label; final Widget label;
final bool isSelected; final bool isSelected;
final bool isFocused; final bool isFocused;
@@ -71,6 +79,7 @@ class NavigationRailItem extends StatelessWidget {
super.key, super.key,
required this.icon, required this.icon,
this.selectedIcon, this.selectedIcon,
this.iconWidget,
required this.label, required this.label,
required this.isSelected, required this.isSelected,
required this.isFocused, required this.isFocused,
@@ -133,12 +142,13 @@ class NavigationRailItem extends StatelessWidget {
padding: .symmetric(vertical: 12, horizontal: horizontalPadding), padding: .symmetric(vertical: 12, horizontal: horizontalPadding),
child: Row( child: Row(
children: [ children: [
AppIcon( iconWidget ??
isSelected && selectedIcon != null ? selectedIcon! : icon, AppIcon(
fill: 1, isSelected && selectedIcon != null ? selectedIcon! : icon,
size: iconSize, fill: 1,
color: isSelected ? t.text : t.textMuted, size: iconSize,
), color: isSelected ? t.text : t.textMuted,
),
const SizedBox(width: 11), const SizedBox(width: 11),
Expanded( Expanded(
child: () { child: () {
@@ -231,6 +241,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
} }
static const _kHome = 'home'; static const _kHome = 'home';
static const _kNowPlaying = 'nowPlaying';
static const _kLibraries = 'libraries'; static const _kLibraries = 'libraries';
static const _kSearch = 'search'; static const _kSearch = 'search';
static const _kDownloads = 'downloads'; static const _kDownloads = 'downloads';
@@ -420,9 +431,11 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
required List<_LibraryNavRow> hiddenRows, required List<_LibraryNavRow> hiddenRows,
required bool hasHiddenLibraries, required bool hasHiddenLibraries,
required bool hasLiveTv, required bool hasLiveTv,
required bool hasNowPlaying,
}) { }) {
return { return {
_kHome, _kHome,
if (hasNowPlaying) _kNowPlaying,
_kLibraries, _kLibraries,
_kSearch, _kSearch,
if (_showDownloads) _kDownloads, if (_showDownloads) _kDownloads,
@@ -497,11 +510,13 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
List<_LibraryNavRow> hiddenRows, { List<_LibraryNavRow> hiddenRows, {
required bool hasHiddenLibraries, required bool hasHiddenLibraries,
required bool hasLiveTv, required bool hasLiveTv,
required bool hasNowPlaying,
}) { }) {
return [ return [
if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect,
if (!widget.isOfflineMode) ...[ if (!widget.isOfflineMode) ...[
_kHome, _kHome,
if (hasNowPlaying) _kNowPlaying,
_kLibraries, _kLibraries,
if (_librariesExpanded) ...[ if (_librariesExpanded) ...[
..._focusKeysForLibraryRows(visibleRows), ..._focusKeysForLibraryRows(visibleRows),
@@ -627,6 +642,10 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv; final hasLiveTv = context.watch<MultiServerProvider>().hasLiveTv;
// Nullable watch: rail tests (and any host without the profile session
// scope) simply never show the Now Playing item.
final musicService = context.watch<MusicPlaybackService?>();
final nowPlayingTrack = widget.isOfflineMode ? null : musicService?.currentTrack;
// Listen to fullscreen + groupLibrariesByServer setting so the rail // Listen to fullscreen + groupLibrariesByServer setting so the rail
// rebuilds when the user toggles "Group libraries by server" in Appearance. // rebuilds when the user toggles "Group libraries by server" in Appearance.
@@ -658,6 +677,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
hiddenRows: hiddenRows, hiddenRows: hiddenRows,
hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasHiddenLibraries: hiddenLibraries.isNotEmpty,
hasLiveTv: hasLiveTv, hasLiveTv: hasLiveTv,
hasNowPlaying: nowPlayingTrack != null,
), ),
); );
final focusOrder = _buildFocusOrder( final focusOrder = _buildFocusOrder(
@@ -665,6 +685,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
hiddenRows, hiddenRows,
hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasHiddenLibraries: hiddenLibraries.isNotEmpty,
hasLiveTv: hasLiveTv, hasLiveTv: hasLiveTv,
hasNowPlaying: nowPlayingTrack != null,
); );
_debugAssertUniqueFocusOrder(focusOrder); _debugAssertUniqueFocusOrder(focusOrder);
return TapRegion( return TapRegion(
@@ -727,6 +748,11 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
isCollapsed: isCollapsed, isCollapsed: isCollapsed,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Now Playing — only while a music session is live.
if (nowPlayingTrack != null && musicService != null) ...[
_buildNowPlayingItem(nowPlayingTrack, musicService, isCollapsed: isCollapsed),
const SizedBox(height: 8),
],
_buildLibrariesSection( _buildLibrariesSection(
visibleRows, visibleRows,
hiddenRows, hiddenRows,
@@ -847,6 +873,50 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
); );
} }
/// "Now Playing" rail item — the shared equalizer as its icon (animating
/// while audio plays), the current track's title as its label. SELECT/tap
/// opens the now-playing screen.
Widget _buildNowPlayingItem(MediaItem track, MusicPlaybackService musicService, {required bool isCollapsed}) {
final t = tokens(context);
final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed);
return NavigationRailItem(
icon: Symbols.music_note_rounded,
iconWidget: SizedBox(
width: 22,
child: Center(
child: EqualizerIcon(animate: musicService.isPlaying, color: t.text),
),
),
label: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
Translations.of(context).music.nowPlaying,
style: TextStyle(fontSize: 14, fontWeight: .w600, color: t.text),
overflow: .ellipsis,
maxLines: 1,
),
Text(
track.title ?? '',
style: TextStyle(fontSize: 11, color: t.textMuted),
overflow: .ellipsis,
maxLines: 1,
),
],
),
isSelected: false,
isFocused: _focusTracker.isFocused(_kNowPlaying),
isCollapsed: isCollapsed,
useSimpleLayout: true,
onTap: () => unawaited(openNowPlaying(context)),
focusNode: _focusTracker.get(_kNowPlaying),
horizontalPadding: itemHorizontalPadding,
onNavigateRight: widget.onNavigateToContent,
);
}
Widget _buildReconnectItem({required bool isCollapsed}) { Widget _buildReconnectItem({required bool isCollapsed}) {
final t = tokens(context); final t = tokens(context);
final isFocused = _focusTracker.isFocused(_kReconnect); final isFocused = _focusTracker.isFocused(_kReconnect);
@@ -16,6 +16,7 @@ import 'package:plezy/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/storage_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -47,6 +48,9 @@ void main() {
); );
final serverManager = MultiServerManager(); final serverManager = MultiServerManager();
final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager));
// The session tree instantiates MusicPlaybackServiceImpl (the mini-player
// overlay watches it), which needs the database + offline watch service.
final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager);
final discoverProviders = <DiscoverProvider>[]; final discoverProviders = <DiscoverProvider>[];
final hiddenProviders = <HiddenLibrariesProvider>[]; final hiddenProviders = <HiddenLibrariesProvider>[];
final disposedActiveIds = <String>[]; final disposedActiveIds = <String>[];
@@ -59,6 +63,7 @@ void main() {
multiServer.dispose(); multiServer.dispose();
serverManager.dispose(); serverManager.dispose();
await plexHome.dispose(); await plexHome.dispose();
offlineWatch.dispose();
await db.close(); await db.close();
}); });
@@ -75,8 +80,10 @@ void main() {
MultiProvider( MultiProvider(
providers: [ providers: [
Provider<StorageService>.value(value: storage), Provider<StorageService>.value(value: storage),
Provider<AppDatabase>.value(value: db),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile), ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer), ChangeNotifierProvider<MultiServerProvider>.value(value: multiServer),
ChangeNotifierProvider<OfflineWatchSyncService>.value(value: offlineWatch),
], ],
child: MaterialApp( child: MaterialApp(
home: ProfileSessionScreen.forTesting( home: ProfileSessionScreen.forTesting(
@@ -184,16 +191,8 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> {
} }
class _FakePlexHomeService extends PlexHomeService { class _FakePlexHomeService extends PlexHomeService {
_FakePlexHomeService({ _FakePlexHomeService({required super.connections, required super.profileConnections, required StorageService storage})
required ConnectionRegistry connections, : super(storage: storage, plexHomeUserFetcher: (_) async => const []);
required ProfileConnectionRegistry profileConnections,
required StorageService storage,
}) : super(
connections: connections,
profileConnections: profileConnections,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
@override @override
Map<String, List<PlexHomeUser>> get current => const {}; Map<String, List<PlexHomeUser>> get current => const {};
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/music/queue_sheet.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/music/track_row.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
MediaItem _track(String id, String title) => MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: title,
parentId: 'album_1',
parentTitle: 'First Light',
grandparentId: 'artist_1',
grandparentTitle: 'Test Artist',
durationMs: 180000,
serverId: 'server_1',
serverName: 'Server',
);
/// Fixed-state fake queue: three tracks, playing the first.
class _FakeQueueService extends StubMusicPlaybackService {
final List<MediaItem> tracks;
final List<int> jumps = [];
_FakeQueueService(this.tracks);
@override
bool get isAvailable => true;
@override
MediaItem? get currentTrack => tracks.first;
@override
MusicPlaybackStatus get status => MusicPlaybackStatus.playing;
@override
List<MediaItem> get queue => tracks;
@override
int get currentIndex => 0;
@override
Future<void> jumpTo(int index) async {
jumps.add(index);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
await SettingsService.getInstance();
});
Widget wrap(MusicPlaybackService service) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(multiServerProvider.dispose);
return TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: const Scaffold(body: SizedBox(width: 500, height: 700, child: QueueSheet())),
),
),
);
}
testWidgets('renders header, pinned current track, and upcoming rows', (tester) async {
final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]);
await tester.pumpWidget(wrap(service));
// pumpAndSettle would never settle: the current-track equalizer animates
// forever while the fake reports "playing".
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// Header: title + total track count.
expect(find.text(t.music.queue), findsOneWidget);
expect(find.text(t.music.trackCount(n: 3)), findsOneWidget);
// Pinned current row (not a TrackRow) + "Up next" label.
expect(find.text('Alpha'), findsOneWidget);
expect(find.text(t.music.upNext), findsOneWidget);
// Upcoming tracks render as TrackRows.
expect(find.byType(TrackRow), findsNWidgets(2));
expect(find.text('Beta'), findsOneWidget);
expect(find.text('Gamma'), findsOneWidget);
});
testWidgets('tapping an upcoming row jumps to its queue index', (tester) async {
final service = _FakeQueueService([_track('t1', 'Alpha'), _track('t2', 'Beta'), _track('t3', 'Gamma')]);
await tester.pumpWidget(wrap(service));
// pumpAndSettle would never settle: the current-track equalizer animates
// forever while the fake reports "playing".
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Gamma'));
await tester.pump();
expect(service.jumps, [2]);
});
}
+148
View File
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/music/mini_player.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
final _track = MediaItem(
id: 'track_1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Dawn',
parentId: 'album_1',
parentTitle: 'First Light',
grandparentId: 'artist_1',
grandparentTitle: 'Test Artist',
durationMs: 180000,
serverId: 'server_1',
serverName: 'Server',
);
/// Fixed-state fake: reports a playing session with a single-track queue.
class _FakeMusicService extends StubMusicPlaybackService {
MediaItem? track;
_FakeMusicService({this.track});
@override
bool get isAvailable => true;
@override
MediaItem? get currentTrack => track;
@override
MusicPlaybackStatus get status => track == null ? MusicPlaybackStatus.idle : MusicPlaybackStatus.playing;
@override
Duration? get duration => track == null ? null : const Duration(minutes: 3);
@override
List<MediaItem> get queue => track == null ? const [] : [track!];
@override
int get currentIndex => track == null ? -1 : 0;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
LocaleSettings.setLocaleSync(AppLocale.en);
await SettingsService.getInstance();
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
Widget wrap({required MusicPlaybackService service, required MusicUiRouteObserver observer}) {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(multiServerProvider.dispose);
return TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<MusicPlaybackService>.value(value: service),
ChangeNotifierProvider<MiniPlayerInsetController>(create: (_) => MiniPlayerInsetController()),
Provider<MusicUiRouteObserver>.value(value: observer),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: const Stack(
children: [
SizedBox.expand(),
Positioned.fill(child: MusicMiniPlayerOverlay()),
],
),
),
),
);
}
testWidgets('appears when the service has a current track', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
expect(find.text('Test Artist'), findsOneWidget);
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('stays hidden while the route observer suppresses it', (tester) async {
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
observer.suppress.value = true;
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
// Suppression lifting brings it back without a rebuild from the service.
observer.suppress.value = false;
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsOneWidget);
});
testWidgets('renders nothing without a current track', (tester) async {
final service = _FakeMusicService();
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
testWidgets('never renders on TV', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
expect(find.text('Dawn'), findsNothing);
expect(find.byType(IconButton), findsNothing);
});
}