diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index 2f6612da..b8dd3cf9 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -79,4 +79,21 @@ class FocusTheme { 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, + ); + } } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 8081c9f7..b84dbd93 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -983,7 +983,23 @@ "trackCount": { "one": "${n} track", "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": { "title": "Watch Together", @@ -1055,6 +1071,8 @@ "manage": "Manage", "tvShows": "TV Shows", "movies": "Movies", + "music": "Music", + "tracksQueued": "${count} tracks queued for download", "noDownloads": "No downloads yet", "noDownloadsDescription": "Downloaded content will appear here for offline viewing", "downloadNow": "Download", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 7054b3c1..5b522737 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20877 (1304 per locale) +/// Strings: 20895 (1305 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 6eb746fa..9936cde4 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2895,6 +2895,54 @@ class TranslationsMusicEn { one: '${n} track', 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 @@ -3115,6 +3163,12 @@ class TranslationsDownloadsEn { /// en: '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' String get noDownloads => 'No downloads yet'; @@ -5566,6 +5620,22 @@ extension on Translations { 'music.addToQueue' => 'Add to queue', '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.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.description' => 'Watch content in sync with friends and family', 'watchTogether.createSession' => 'Create Session', @@ -5633,6 +5703,8 @@ extension on Translations { 'downloads.manage' => 'Manage', 'downloads.tvShows' => 'TV Shows', 'downloads.movies' => 'Movies', + 'downloads.music' => 'Music', + 'downloads.tracksQueued' => ({required Object count}) => '${count} tracks queued for download', 'downloads.noDownloads' => 'No downloads yet', 'downloads.noDownloadsDescription' => 'Downloaded content will appear here for offline viewing', 'downloads.downloadNow' => 'Download', @@ -5669,6 +5741,8 @@ extension on Translations { 'downloads.customAmount' => 'Custom amount...', 'downloads.includeSpecials' => 'Include Specials', 'downloads.howManyEpisodes' => 'How many episodes?', + _ => null, + } ?? switch (path) { 'downloads.itemsQueued' => ({required Object count}) => '${count} items queued for download', 'downloads.keepSynced' => 'Keep synced', 'downloads.downloadOnce' => 'Download once', @@ -5687,8 +5761,6 @@ extension on Translations { 'downloads.editSyncFilter' => 'Sync filter', 'downloads.syncAllItems' => 'Syncing all items', 'downloads.syncUnwatchedItems' => 'Syncing unwatched items', - _ => null, - } ?? switch (path) { 'downloads.syncRuleServerContext' => ({required Object server, required Object status}) => 'Server: ${server} • ${status}', 'downloads.syncRuleAvailable' => 'Available', 'downloads.syncRuleOffline' => 'Offline', diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index a79baed4..2a7d6b04 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -28,6 +28,7 @@ import '../services/offline_watch_sync_service.dart'; import '../services/storage_service.dart'; import '../utils/app_logger.dart'; import '../watch_together/providers/watch_together_provider.dart'; +import '../widgets/music/mini_player.dart'; import 'profile_navigation_scope.dart'; /// Root route for an active profile session. @@ -228,6 +229,12 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> { final _mainScaffoldMessengerKey = GlobalKey(); final _routeObserver = RouteObserver>(); + // 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 void initState() { super.initState(); @@ -239,6 +246,8 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> { void dispose() { profileNavigationRegistry.detachNavigator(_navigatorKey); profileNavigationRegistry.detachMainScaffoldMessenger(_mainScaffoldMessengerKey); + _miniPlayerInsets.dispose(); + _musicRouteObserver.suppress.dispose(); super.dispose(); } @@ -254,10 +263,24 @@ class _ProfileSessionNavigatorState extends State<_ProfileSessionNavigator> { if (didPop) return; unawaited(_navigatorKey.currentState?.maybePop()); }, - child: Navigator( - key: _navigatorKey, - observers: [_routeObserver, BackKeySuppressorObserver()], - onGenerateRoute: _onGenerateRoute, + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _miniPlayerInsets), + Provider.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()), + ], + ), ), ), ); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 8e46f6f9..67c4236c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -50,6 +50,7 @@ import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../providers/companion_remote_provider.dart'; import '../utils/desktop_window_padding.dart'; +import '../widgets/music/mini_player.dart'; import '../widgets/side_navigation_rail.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; @@ -224,6 +225,10 @@ class _MainScreenState extends State final GlobalKey> _settingsKey = GlobalKey(); final GlobalKey _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 final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); @@ -746,6 +751,8 @@ class _MainScreenState extends State _setupCompanionRemote(); } + _miniPlayerInsets = context.read(); + final scopedRouteObserver = ProfileNavigationScope.of(context).routeObserver; if (scopedRouteObserver != _profileRouteObserver) { _profileRouteObserver?.unsubscribe(this); @@ -1281,6 +1288,9 @@ class _MainScreenState extends State @override void didPushNext() { _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) if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { @@ -1302,6 +1312,7 @@ class _MainScreenState extends State // Called when returning to this route from a child route (e.g., from video player) _updateTvosMenuPassthrough(); + _miniPlayerInsets?.setNavBarSuspended(false); if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { aware.onTabShown(); @@ -1589,6 +1600,19 @@ class _MainScreenState extends State ); } + /// 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 Widget build(BuildContext context) { final useSideNav = PlatformDetector.shouldUseSideNavigation(context); @@ -1732,6 +1756,7 @@ class _MainScreenState extends State child: Scaffold( body: _buildTickerAwareStack(), bottomNavigationBar: Column( + key: _bottomBarKey, mainAxisSize: .min, children: [ // Reconnect bar when offline @@ -1774,6 +1799,10 @@ class _MainScreenState extends State pref: SettingsService.showNavBarLabels, builder: (context, 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( data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), child: _buildBottomNavigationBar(context, hideLabels: hideLabels), diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index ffc1ed49..c3fedf28 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -2,6 +2,7 @@ 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/focusable_action_bar.dart'; @@ -11,9 +12,12 @@ import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.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 '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; +import '../../utils/dialogs.dart'; import '../../utils/formatters.dart'; import '../../utils/layout_constants.dart'; import '../../utils/media_image_helper.dart'; @@ -23,8 +27,10 @@ import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.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/media_context_menu.dart'; +import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/music/track_row.dart'; import '../../widgets/optimized_media_image.dart'; @@ -149,6 +155,98 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen _handleDownloadPressed() async { + final downloadProvider = context.read(); + 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() == null) return null; + + return FocusableAction( + debugLabel: 'album_download', + onPressed: () => unawaited(_handleDownloadPressed()), + builder: (context, state) => Consumer( + 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 List getAppBarActions() { final client = context.tryGetMediaClientWithFallback(serverIdOrNull(widget.album.serverId)); @@ -158,6 +256,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen unawaited(playInstantMix(context, widget.album)) : null, + download: _downloadAction(), trailing: _overflowAction(), ); } @@ -301,6 +400,7 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), + ), ], ), ), diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index f720b8c9..16e46559 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; import '../../focus/key_event_utils.dart'; @@ -20,6 +21,7 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/collapsible_text.dart'; import '../../widgets/desktop_app_bar.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/optimized_media_image.dart'; import '../../widgets/overlay_sheet.dart'; @@ -198,6 +200,10 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), + ), ], ), ), diff --git a/lib/screens/music/now_playing_screen.dart b/lib/screens/music/now_playing_screen.dart new file mode 100644 index 00000000..d02edfb3 --- /dev/null +++ b/lib/screens/music/now_playing_screen.dart @@ -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 createState() => _NowPlayingScreenState(); +} + +class _NowPlayingScreenState extends State with ContextMenuTapMixin { + MusicPlaybackService? _service; + StreamSubscription? _errorsSub; + + bool _showLyrics = false; + final Map> _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 _utilityBarKey = GlobalKey(); + + bool _overflowFocused = false; + bool _poppedForIdle = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final service = context.read(); + 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 _lyricsFutureFor(MediaItem track) => + _lyricsCache.putIfAbsent(track.globalKey, () => context.read().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 _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 _showSleepTimerSheet() async { + final service = context.read(); + final timed = service.sleepTimerActive && !service.sleepTimerEndOfTrack; + final selected = await OverlaySheetController.showAdaptive( + context, + showDragHandle: true, + builder: (context) => AppMenuSheet( + 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(); + 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((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((s) => s.isPlaying); + final isLoading = context.select((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(); + 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(); + final showFocus = _focused && InputModeTracker.isKeyboardMode(context); + + final bar = StreamBuilder( + 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, + ), + ); + } +} diff --git a/lib/screens/music/queue_sheet.dart b/lib/screens/music/queue_sheet.dart new file mode 100644 index 00000000..0892476f --- /dev/null +++ b/lib/screens/music/queue_sheet.dart @@ -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 showQueueSheet(BuildContext context) { + return OverlaySheetController.showAdaptive(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(); + 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(); + 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 []; + + // Stable per-item keys that survive reorders; the same track can appear + // more than once, so disambiguate repeats by occurrence. + final seen = {}; + 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((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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index afd2fc6c..3d9b0519 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -9,8 +9,10 @@ import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_playlist.dart'; import '../../services/media_list_playback_launcher.dart'; +import '../../services/music/music_playback_service.dart'; import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; +import '../../utils/music_navigation.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../focus/dpad_navigator.dart'; @@ -69,20 +71,60 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen 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 playItems() => _isAudioPlaylist ? _playAudioPlaylist(shuffle: false) : super.playItems(); + + @override + Future 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 _playAudioPlaylist({required bool shuffle, MediaItem? startTrack}) async { + if (items.isEmpty) { + showAppSnackBar(context, emptyMessage); + return; + } + if (!ensureMusicPlaybackAvailable(context)) return; + List 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 List 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(); // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. - final hasRule = isVideoPlaylist && context.select((p) => p.hasSyncRule(ruleKey)); + final hasRule = isDownloadablePlaylist && context.select((p) => p.hasSyncRule(ruleKey)); return [ if (items.isNotEmpty) ...[ FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), 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( icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, @@ -501,6 +543,10 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen= items.length) return; final selectedItem = items[index]; + if (_isAudioPlaylist) { + await _playAudioPlaylist(shuffle: false, startTrack: selectedItem); + return; + } final launcher = MediaListPlaybackLauncher.forItem(context, widget.playlist); await launcher.launchFromCollectionOrPlaylist( item: widget.playlist, diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 18236a89..e3929b4a 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -246,6 +246,10 @@ class _PlaylistItemCardState extends State with ContextMenuTap return '$year · $edition'; } 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 diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index deb6b19b..f6eae3e8 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -3,21 +3,91 @@ import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; +import '../navigation/profile_navigation_scope.dart'; import '../screens/music/album_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 '../theme/mono_motion.dart'; +import '../theme/mono_tokens.dart'; import 'app_logger.dart'; +import 'platform_detector.dart'; import 'provider_extensions.dart'; import 'snackbar_helper.dart'; -/// Push the artist detail screen for [artist] on the nearest navigator. -Future navigateToArtist(BuildContext context, MediaItem artist) async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => ArtistDetailScreen(artist: artist))); +/// Route name of the now-playing screen — the mini-player's route observer +/// suppresses itself while this (or the video player) is in the stack. +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 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 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 openNowPlaying(BuildContext context) async { + final navigator = _contentNavigatorOf(context); + if (_isRouteOnTop(navigator, kNowPlayingRouteName)) return; + final duration = DevicePerformance.reducedDuration(tokens(context).expressive); + await navigator.push( + PageRouteBuilder( + 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(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().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 @@ -45,6 +115,7 @@ Future playTracks( playContext: playContext, shuffle: shuffle, ); + if (context.mounted) _autoOpenNowPlayingOnTv(context); } /// Play [track] within its album queue: fetch the album's tracks and start @@ -88,4 +159,5 @@ Future playTrackWithAlbumContext(BuildContext context, MediaItem track) as Future playInstantMix(BuildContext context, MediaItem seed) async { if (!ensureMusicPlaybackAvailable(context)) return; await context.read().playInstantMix(seed); + if (context.mounted) _autoOpenNowPlayingOnTv(context); } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index dbf8752e..cc5a03af 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -82,6 +82,16 @@ bool isAdminActionAllowedForMediaItem({ /// 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. +/// 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 { /// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because /// Dart has no nominal union type — guarded at runtime via the @@ -102,6 +112,9 @@ class MediaContextMenu extends StatefulWidget { final bool isInContinueWatching; final String? collectionId; // The collection ID if displaying within a collection + /// Extra entries appended after the standard actions. + final List extraEntries; + const MediaContextMenu({ super.key, required this.item, @@ -113,6 +126,7 @@ class MediaContextMenu extends StatefulWidget { required this.child, this.isInContinueWatching = false, this.collectionId, + this.extraEntries = const [], }); @override @@ -253,10 +267,12 @@ class MediaContextMenuState extends State { menuActions.add(_MenuAction(value: 'shuffle', icon: Symbols.shuffle_rounded, label: t.mediaMenu.shufflePlay)); - // Download + sync-rule management. Video playlists and any collection - // qualify — collections can contain movies, episodes, and shows. - final isVideoPlaylist = isPlaylist && playlist.playlistType == 'video'; - if ((isVideoPlaylist || isCollection) && !PlatformDetector.isAppleTV()) { + // Download + sync-rule management. Video and audio playlists and any + // collection qualify — collections can contain movies, episodes, + // shows, albums, and artists; audio playlists queue their tracks. + final isDownloadablePlaylist = + isPlaylist && (playlist.playlistType == 'video' || playlist.playlistType == 'audio'); + if ((isDownloadablePlaylist || isCollection) && !PlatformDetector.isAppleTV()) { final hasRule = Provider.of(context, listen: false).hasSyncRule(_itemSyncRuleKey(context)); if (hasRule) { menuActions.add( @@ -473,13 +489,17 @@ class MediaContextMenuState extends State { ); } - // Download options (for episodes, movies, shows, and seasons). - // Apple TV has no user-accessible file storage — skip entirely. + // Download options (for episodes, movies, shows, seasons, albums, and + // 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() && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie || mediaKind == MediaKind.show || - mediaKind == MediaKind.season)) { + mediaKind == MediaKind.season || + mediaKind == MediaKind.album || + mediaKind == MediaKind.track)) { final downloadProvider = Provider.of(context, listen: false); final globalKey = mediaItem.globalKey; final hasSyncRule = downloadProvider.hasSyncRule(_itemSyncRuleKey(context)); @@ -549,6 +569,11 @@ class MediaContextMenuState extends State { } } + 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; final openedFromKeyboard = _openedFromKeyboard; @@ -585,6 +610,15 @@ class MediaContextMenuState extends State { try { 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) { case 'play_from_beginning': didNavigate = true; diff --git a/lib/widgets/music/equalizer_icon.dart b/lib/widgets/music/equalizer_icon.dart new file mode 100644 index 00000000..26abd8b5 --- /dev/null +++ b/lib/widgets/music/equalizer_icon.dart @@ -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 createState() => _EqualizerIconState(); +} + +class _EqualizerIconState extends State 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 _staticHeights = [0.55, 0.9, 0.4]; + + /// Per-bar phase offsets so the animated bars move out of step. + static const List _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; +} diff --git a/lib/widgets/music/lyrics_view.dart b/lib/widgets/music/lyrics_view.dart new file mode 100644 index 00000000..954644a3 --- /dev/null +++ b/lib/widgets/music/lyrics_view.dart @@ -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 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 createState() => _LyricsViewState(); +} + +class _LyricsViewState extends State { + static const Duration _manualScrollHold = Duration(seconds: 3); + static const double _unsyncedScrollStep = 64; + + final ScrollController _scroll = ScrollController(); + + Lyrics? _lyrics; + bool _loading = true; + List _lineKeys = const []; + + StreamSubscription? _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().positionStream.listen(_onPosition); + } + + @override + void dispose() { + _positionSub?.cancel(); + _scroll.dispose(); + super.dispose(); + } + + void _resolve(Future 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().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().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( + 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, + ), + ), + ); + } +} diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart new file mode 100644 index 00000000..b76ae0b0 --- /dev/null +++ b/lib/widgets/music/mini_player.dart @@ -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 suppress = ValueNotifier(false); + + final List> _stack = []; + + static bool _isSuppressingRoute(Route 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 route, Route? previousRoute) { + _stack.add(route); + _recompute(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _stack.remove(route); + _recompute(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _stack.remove(route); + _recompute(); + } + + @override + void didReplace({Route? newRoute, Route? 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 createState() => _MusicMiniPlayerOverlayState(); +} + +class _MusicMiniPlayerOverlayState extends State { + static const double _cardHeight = 64; + static final ValueNotifier _noSuppression = ValueNotifier(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(); + if (controller == null || controller.overlayHeight == height) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) controller.setOverlayHeight(height); + }); + } + + void _handleDismissed() { + setState(() => _dismissed = true); + unawaited(context.read().stop()); + } + + @override + Widget build(BuildContext context) { + if (PlatformDetector.isTV()) return const SizedBox.shrink(); + + final track = context.select((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()?.suppress ?? _noSuppression; + return ValueListenableBuilder( + 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(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((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(); + final isPlaying = context.select((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(); + final tk = tokens(context); + return StreamBuilder( + 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)), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/music/music_actions.dart b/lib/widgets/music/music_actions.dart index 99846e28..6c1668ed 100644 --- a/lib/widgets/music/music_actions.dart +++ b/lib/widgets/music/music_actions.dart @@ -7,13 +7,15 @@ import '../app_icon.dart'; /// Standard action list for the music detail screens (album/artist): /// a labeled Play pill, a shuffle icon, an optional Instant Mix icon (pass -/// null when the server lacks the capability), and an optional [trailing] -/// action (the album overflow ⋮). Render inside a [FocusableActionBar] — +/// null when the server lacks the capability), an optional [download] +/// 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. List buildMusicActions({ required VoidCallback onPlay, required VoidCallback onShuffle, VoidCallback? onInstantMix, + FocusableAction? download, FocusableAction? trailing, }) { return [ @@ -35,6 +37,7 @@ List buildMusicActions({ tooltip: t.music.instantMix, onPressed: onInstantMix, ), + ?download, ?trailing, ]; } diff --git a/lib/widgets/music/repeat_mode.dart b/lib/widgets/music/repeat_mode.dart new file mode 100644 index 00000000..584a18f8 --- /dev/null +++ b/lib/widgets/music/repeat_mode.dart @@ -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, +}; diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart index 3cad3f71..4038cac1 100644 --- a/lib/widgets/music/track_row.dart +++ b/lib/widgets/music/track_row.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -9,12 +7,15 @@ import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; import '../../media/media_item.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 '../../theme/mono_tokens.dart'; import '../../utils/formatters.dart'; import '../app_icon.dart'; +import '../download_status_icon.dart'; import '../media_context_menu.dart'; +import 'equalizer_icon.dart'; /// List row for a music track: /// `[track # | equalizer] [title + optional artist] [duration] [⋮]`. @@ -56,6 +57,24 @@ class TrackRow extends StatefulWidget { final ValueChanged? 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({ super.key, required this.item, @@ -68,6 +87,10 @@ class TrackRow extends StatefulWidget { this.onNavigateUp, this.onBack, this.onFocusChange, + this.trailingIcon, + this.onTrailingTap, + this.enableContextMenu = true, + this.showDownloadStatus = false, }); @override @@ -111,6 +134,8 @@ class _TrackRowState extends State with ContextMenuTapMixin, void _activateFocusedColumn() { if (_focusedColumn == 0) { widget.onTap?.call(); + } else if (widget.onTrailingTap != null) { + widget.onTrailingTap!(); } else { // Keyboard/gamepad activation — the menu centers on the row. showContextMenu(); @@ -163,202 +188,156 @@ class _TrackRowState extends State with ContextMenuTapMixin, final subtitle = _subtitle; final durationMs = widget.item.durationMs; + final withContextMenu = widget.enableContextMenu; - return MediaContextMenu( - key: contextMenuKey, - item: widget.item, - onRefresh: widget.onRefresh, - onTap: widget.onTap, - child: Focus( - focusNode: effectiveFocusNode, - descendantsAreFocusable: false, - onKeyEvent: _handleKeyEvent, - onFocusChange: _handleFocusChange, - child: Material( - color: tk.surface, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: radii), - child: InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: widget.onTap, - onTapDown: storeTapPosition, - onLongPress: showContextMenuFromTap, - onSecondaryTapDown: storeTapPosition, - onSecondaryTap: showContextMenuFromTap, - child: Container( - height: TrackRow.height, - // Text-based fill (mono theme focusColor convention) — the - // white-based FocusTheme fill is invisible on the light row - // surface. - decoration: BoxDecoration( - borderRadius: radii, - color: showFocus && _focusedColumn == 0 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, - ), - padding: const EdgeInsets.only(left: 12, right: 4), - child: Row( - children: [ - SizedBox( - width: 32, - child: Center( - child: isCurrent - ? _EqualizerIcon(animate: serviceIsPlaying, color: colorScheme.primary) - : Text( - '${widget.item.trackNumber ?? ''}', - style: TextStyle(fontSize: 13, color: tk.textMuted), - ), - ), + final row = Focus( + focusNode: effectiveFocusNode, + descendantsAreFocusable: false, + onKeyEvent: _handleKeyEvent, + onFocusChange: _handleFocusChange, + child: Material( + color: tk.surface, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: radii), + child: InkWell( + mouseCursor: SystemMouseCursors.click, + onTap: widget.onTap, + onTapDown: withContextMenu ? storeTapPosition : null, + onLongPress: withContextMenu ? showContextMenuFromTap : null, + onSecondaryTapDown: withContextMenu ? storeTapPosition : null, + onSecondaryTap: withContextMenu ? showContextMenuFromTap : null, + child: Container( + height: TrackRow.height, + // Text-based fill (mono theme focusColor convention) — the + // white-based FocusTheme fill is invisible on the light row + // surface. + decoration: BoxDecoration( + borderRadius: radii, + color: showFocus && _focusedColumn == 0 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, + ), + padding: const EdgeInsets.only(left: 12, right: 4), + child: Row( + children: [ + SizedBox( + 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( - child: Column( - mainAxisAlignment: .center, - crossAxisAlignment: .start, - children: [ + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: .center, + 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( - widget.item.title ?? '', + subtitle, maxLines: 1, overflow: .ellipsis, - style: TextStyle( - fontSize: 14, - fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400, - color: isCurrent ? tk.text : null, - ), + style: TextStyle(fontSize: 12, color: tk.textMuted), ), - if (subtitle != null) - Text( - subtitle, - maxLines: 1, - overflow: .ellipsis, - style: TextStyle(fontSize: 12, color: tk.textMuted), - ), - ], - ), + ], ), - const SizedBox(width: 8), - if (durationMs != null) - Text( - formatDurationTimestamp(Duration(milliseconds: durationMs)), - style: TextStyle(fontSize: 13, color: tk.textMuted), - ), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: showFocus && _focusedColumn == 1 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, - ), - child: Builder( - builder: (buttonContext) => IconButton( - icon: AppIcon(Symbols.more_vert_rounded, fill: 1, size: 20, color: tk.textMuted), - onPressed: () => _showMenuAt(buttonContext), + ), + const SizedBox(width: 8), + if (durationMs != null) + Text( + formatDurationTimestamp(Duration(milliseconds: durationMs)), + style: TextStyle(fontSize: 13, color: tk.textMuted), + ), + if (widget.showDownloadStatus) _TrackDownloadStatus(item: widget.item), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: showFocus && _focusedColumn == 1 ? tk.text.withValues(alpha: 0.12) : Colors.transparent, + ), + child: Builder( + 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; -/// on the reduced visual-effects tier they render static regardless (each -/// animation frame re-rasterizes the row on weak TV GPUs). -class _EqualizerIcon extends StatefulWidget { - final bool animate; - final Color color; - - 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), - ), - ), + if (!withContextMenu) return row; + return MediaContextMenu( + key: contextMenuKey, + item: widget.item, + onRefresh: widget.onRefresh, + onTap: widget.onTap, + child: row, ); } } -class _EqualizerPainter extends CustomPainter { - final double t; - final Color color; - final bool animate; +/// Compact per-track download indicator, mirroring the episode-card pattern: +/// a queueing spinner while the queue is being built, then a muted +/// [DownloadStatusIcon] for any live download record. A [Selector] slice +/// 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. - static const List _staticHeights = [0.55, 0.9, 0.4]; - - /// Per-bar phase offsets so the animated bars move out of step. - static const List _phases = [0.0, 0.35, 0.7]; - - const _EqualizerPainter({required this.t, required this.color, required this.animate}); + const _TrackDownloadStatus({required this.item}); @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, - ); + Widget build(BuildContext context) { + // Graceful no-op when no DownloadProvider is in scope (tests, playback + // queue contexts) or the item can't have a download record. + if (item.serverId == null || context.read() == null) { + return const SizedBox.shrink(); } - } - @override - bool shouldRepaint(_EqualizerPainter oldDelegate) => - oldDelegate.t != t || oldDelegate.color != color || oldDelegate.animate != animate; + return Selector( + selector: (_, p) { + 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); + }, + ); + } } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index bc36e83b..0f63a753 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -10,15 +10,19 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/focus_memory_tracker.dart'; +import '../media/media_item.dart'; import '../media/media_library.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; +import '../services/music/music_playback_service.dart'; import '../services/settings_service.dart'; +import '../utils/music_navigation.dart'; import '../utils/platform_detector.dart'; import '../utils/scroll_utils.dart'; import '../utils/library_grouping.dart'; +import 'music/equalizer_icon.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; import '../theme/mono_tokens.dart'; @@ -51,6 +55,10 @@ final class _LibraryItemRow extends _LibraryNavRow { class NavigationRailItem extends StatelessWidget { final IconData icon; 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 bool isSelected; final bool isFocused; @@ -71,6 +79,7 @@ class NavigationRailItem extends StatelessWidget { super.key, required this.icon, this.selectedIcon, + this.iconWidget, required this.label, required this.isSelected, required this.isFocused, @@ -133,12 +142,13 @@ class NavigationRailItem extends StatelessWidget { padding: .symmetric(vertical: 12, horizontal: horizontalPadding), child: Row( children: [ - AppIcon( - isSelected && selectedIcon != null ? selectedIcon! : icon, - fill: 1, - size: iconSize, - color: isSelected ? t.text : t.textMuted, - ), + iconWidget ?? + AppIcon( + isSelected && selectedIcon != null ? selectedIcon! : icon, + fill: 1, + size: iconSize, + color: isSelected ? t.text : t.textMuted, + ), const SizedBox(width: 11), Expanded( child: () { @@ -231,6 +241,7 @@ class SideNavigationRailState extends State with MountedSetS } static const _kHome = 'home'; + static const _kNowPlaying = 'nowPlaying'; static const _kLibraries = 'libraries'; static const _kSearch = 'search'; static const _kDownloads = 'downloads'; @@ -420,9 +431,11 @@ class SideNavigationRailState extends State with MountedSetS required List<_LibraryNavRow> hiddenRows, required bool hasHiddenLibraries, required bool hasLiveTv, + required bool hasNowPlaying, }) { return { _kHome, + if (hasNowPlaying) _kNowPlaying, _kLibraries, _kSearch, if (_showDownloads) _kDownloads, @@ -497,11 +510,13 @@ class SideNavigationRailState extends State with MountedSetS List<_LibraryNavRow> hiddenRows, { required bool hasHiddenLibraries, required bool hasLiveTv, + required bool hasNowPlaying, }) { return [ if (widget.isOfflineMode && widget.onReconnect != null) _kReconnect, if (!widget.isOfflineMode) ...[ _kHome, + if (hasNowPlaying) _kNowPlaying, _kLibraries, if (_librariesExpanded) ...[ ..._focusKeysForLibraryRows(visibleRows), @@ -627,6 +642,10 @@ class SideNavigationRailState extends State with MountedSetS final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; + // Nullable watch: rail tests (and any host without the profile session + // scope) simply never show the Now Playing item. + final musicService = context.watch(); + final nowPlayingTrack = widget.isOfflineMode ? null : musicService?.currentTrack; // Listen to fullscreen + groupLibrariesByServer setting so the rail // rebuilds when the user toggles "Group libraries by server" in Appearance. @@ -658,6 +677,7 @@ class SideNavigationRailState extends State with MountedSetS hiddenRows: hiddenRows, hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, + hasNowPlaying: nowPlayingTrack != null, ), ); final focusOrder = _buildFocusOrder( @@ -665,6 +685,7 @@ class SideNavigationRailState extends State with MountedSetS hiddenRows, hasHiddenLibraries: hiddenLibraries.isNotEmpty, hasLiveTv: hasLiveTv, + hasNowPlaying: nowPlayingTrack != null, ); _debugAssertUniqueFocusOrder(focusOrder); return TapRegion( @@ -727,6 +748,11 @@ class SideNavigationRailState extends State with MountedSetS isCollapsed: isCollapsed, ), 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( visibleRows, hiddenRows, @@ -847,6 +873,50 @@ class SideNavigationRailState extends State 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}) { final t = tokens(context); final isFocused = _focusTracker.isFocused(_kReconnect); diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 921215a8..c878235e 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -16,6 +16,7 @@ import 'package:plezy/providers/hidden_libraries_provider.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/offline_watch_sync_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:provider/provider.dart'; @@ -47,6 +48,9 @@ void main() { ); final serverManager = MultiServerManager(); 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 = []; final hiddenProviders = []; final disposedActiveIds = []; @@ -59,6 +63,7 @@ void main() { multiServer.dispose(); serverManager.dispose(); await plexHome.dispose(); + offlineWatch.dispose(); await db.close(); }); @@ -75,8 +80,10 @@ void main() { MultiProvider( providers: [ Provider.value(value: storage), + Provider.value(value: db), ChangeNotifierProvider.value(value: activeProfile), ChangeNotifierProvider.value(value: multiServer), + ChangeNotifierProvider.value(value: offlineWatch), ], child: MaterialApp( home: ProfileSessionScreen.forTesting( @@ -184,16 +191,8 @@ class _ProfileProbeShellState extends State<_ProfileProbeShell> { } class _FakePlexHomeService extends PlexHomeService { - _FakePlexHomeService({ - required ConnectionRegistry connections, - required ProfileConnectionRegistry profileConnections, - required StorageService storage, - }) : super( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); + _FakePlexHomeService({required super.connections, required super.profileConnections, required StorageService storage}) + : super(storage: storage, plexHomeUserFetcher: (_) async => const []); @override Map> get current => const {}; diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart new file mode 100644 index 00000000..4badc31f --- /dev/null +++ b/test/screens/music/queue_sheet_test.dart @@ -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 tracks; + final List jumps = []; + + _FakeQueueService(this.tracks); + + @override + bool get isAvailable => true; + + @override + MediaItem? get currentTrack => tracks.first; + + @override + MusicPlaybackStatus get status => MusicPlaybackStatus.playing; + + @override + List get queue => tracks; + + @override + int get currentIndex => 0; + + @override + Future 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.value(value: multiServerProvider), + ChangeNotifierProvider.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]); + }); +} diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart new file mode 100644 index 00000000..73e82a20 --- /dev/null +++ b/test/widgets/music/mini_player_test.dart @@ -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 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.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: service), + ChangeNotifierProvider(create: (_) => MiniPlayerInsetController()), + Provider.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); + }); +}