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:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user