From c68ffe9ed03db08bf4f4d94b78b4bcdf73789e20 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:02:03 +0200 Subject: [PATCH] refactor: share the toolbar scrim and dedupe playback and download paths Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds duplicated request/retry handling in the media server HTTP client, and collapses the parallel playback-source, download-manager and live TV helper paths into shared implementations. --- lib/providers/playback_state_provider.dart | 51 +--- lib/screens/discover_screen.dart | 262 +++++++--------- lib/screens/explore_screen.dart | 113 +++---- lib/screens/livetv/live_tv_screen.dart | 95 +++--- .../parts/episode_navigation.dart | 20 +- .../video_player/parts/playback_start.dart | 18 +- lib/screens/video_player_screen.dart | 20 +- lib/services/download_manager_service.dart | 167 +++++----- lib/services/download_storage_service.dart | 23 ++ lib/services/music/music_source_resolver.dart | 22 +- .../playback_initialization_service.dart | 35 +-- lib/services/playback_source_resolver.dart | 42 +-- lib/services/plex_client/parts/live_tv.dart | 56 +--- lib/utils/desktop_window_padding.dart | 44 +-- lib/utils/media_navigation_helper.dart | 14 +- lib/utils/media_server_http_client.dart | 287 +++++++++--------- lib/utils/plex_library_section_helpers.dart | 31 -- lib/widgets/toolbar_scrim.dart | 39 +++ lib/widgets/tv_spotlight_scaffold.dart | 24 ++ .../helpers/track_selection_helper.dart | 4 +- .../video_controls/sheets/track_sheet.dart | 14 +- ...ack_initialization_offline_cache_test.dart | 130 ++++---- .../playback_source_resolver_test.dart | 28 +- 23 files changed, 699 insertions(+), 840 deletions(-) delete mode 100644 lib/utils/plex_library_section_helpers.dart create mode 100644 lib/widgets/toolbar_scrim.dart diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 377f99cf..b54f93d1 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { var anchor = current; // Bounded so a pathological all-same-file queue cannot spin. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemAfter(anchor); + final result = await _itemAtOffset(anchor, 1); final candidate = result.item; if (result.status != QueueNavigationStatus.found || candidate == null) { return result; @@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { final current = _loadedItems[indexResult.index!]; MediaItem candidate = current; for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status != QueueNavigationStatus.found || before == null) { return result; @@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { // Collapse to the first episode of the candidate's same-file group. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status == QueueNavigationStatus.failed) return result; if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { @@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return QueueNavigationResult.found(candidate); } - /// The queue item immediately after [anchor], extending a server-backed + /// The queue item [delta] steps from [anchor], extending a server-backed /// window when needed. The centered response proves whether [anchor] is at /// the global boundary; a window-local index is never compared with the /// queue's global item count. - Future _itemAfter(MediaItem anchor) async { + Future _itemAtOffset(MediaItem anchor, int delta) async { final anchorId = playQueueItemIdFor(anchor); if (anchorId == null) return const QueueNavigationResult.unavailable(); var anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - if (anchorIndex + 1 < _loadedItems.length) { - return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); + var target = anchorIndex + delta; + if (target >= 0 && target < _loadedItems.length) { + return QueueNavigationResult.found(_loadedItems[target]); } // Local queues are fully resident, so their window edge is the queue edge. @@ -382,43 +383,15 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } // Refresh around the actual anchor. Queue ids are opaque and need not be - // consecutive, so never guess `anchorId + 1`. + // consecutive, so never guess the neighbour's id. if (!await _loadServerWindow(anchorId)) { return const QueueNavigationResult.failed(); } anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex + 1 < _loadedItems.length - ? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) - : const QueueNavigationResult.boundary(); - } - - /// The queue item immediately before [anchor], extending a server-backed - /// window when needed. - Future _itemBefore(MediaItem anchor) async { - final anchorId = playQueueItemIdFor(anchor); - if (anchorId == null) return const QueueNavigationResult.unavailable(); - var anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - - if (anchorIndex > 0) { - return QueueNavigationResult.found(_loadedItems[anchorIndex - 1]); - } - - if (_windowFetcher == null || _playQueueId == null) { - return const QueueNavigationResult.boundary(); - } - if (_playQueueTotalCount > 0 && _loadedItems.length >= _playQueueTotalCount) { - return const QueueNavigationResult.boundary(); - } - - if (!await _loadServerWindow(anchorId)) { - return const QueueNavigationResult.failed(); - } - anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex > 0 - ? QueueNavigationResult.found(_loadedItems[anchorIndex - 1]) + target = anchorIndex + delta; + return target >= 0 && target < _loadedItems.length + ? QueueNavigationResult.found(_loadedItems[target]) : const QueueNavigationResult.boundary(); } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 43c6414f..ad54cc7a 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -25,7 +25,7 @@ import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; import '../widgets/cycling_media_backdrop.dart'; import '../widgets/optimized_media_image.dart' show blurArtwork; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; @@ -741,153 +741,125 @@ class _DiscoverScreenState extends State } Widget _buildOverlaidAppBar() { - final statusBarHeight = MediaQuery.paddingOf(context).top; final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; final foregroundColor = colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - if (!PlatformDetector.isTV()) - Text( - t.discover.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), - ), - const Spacer(), - Consumer2( - builder: (context, watchTogether, companionRemote, _) { - final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); + return ToolbarScrim( + child: Row( + children: [ + if (!PlatformDetector.isTV()) + Text( + t.discover.title, + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), + ), + const Spacer(), + Consumer2( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); - return FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _focusContentFromAppBar, - actions: [ - FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - onPressed: _discover.load, - ), - // Watch Together - FocusableAction( - onPressed: () => - Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _focusContentFromAppBar, + actions: [ + FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load), + // Watch Together + FocusableAction( + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.group_rounded, + fill: watchTogether.isInSession ? 1 : 0, + color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + ), + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + tooltip: t.watchTogether.title, + ), + if (watchTogether.isInSession && watchTogether.participantCount > 1) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - onPressed: () => Navigator.push( + child: Text( + '${watchTogether.participantCount}', + style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), + ), + ), + ), + ], + ), + ), + // Companion Remote + FocusableAction( + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); + } + }, + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.phone_android_rounded, + fill: companionRemote.isConnected ? 1 : 0, + color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, + ), + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push( context, - MaterialPageRoute(builder: (_) => const WatchTogetherScreen()), + MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), + ); + } + }, + tooltip: t.companionRemote.title, + ), + if (companionRemote.isConnected) + Positioned( + top: 6, + right: 6, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), ), - tooltip: t.watchTogether.title, ), - if (watchTogether.isInSession && watchTogether.participantCount > 1) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - '${watchTogether.participantCount}', - style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), - ), - ), - ), - ], - ), - ), - // Companion Remote - FocusableAction( - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.phone_android_rounded, - fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, - ), - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - tooltip: t.companionRemote.title, - ), - if (companionRemote.isConnected) - Positioned( - top: 6, - right: 6, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), - ), - ), - ), - ], - ), - ), - // Server Tasks — Plex-only (`/activities` API has no - // Jellyfin equivalent), hide the button entirely on - // Jellyfin-only profiles so the chrome doesn't show - // a permanently empty popover. - if (PlatformDetector.isDesktop(context) && - context.select((p) => p.hasOnlinePlexServers)) - FocusableAction( - onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), - child: ServerActivitiesButton(key: _serverActivitiesButtonKey), - ), - // User menu — profiles + sign out - _buildUserMenuAction(context), - ], - ); - }, - ), - ], + ), + ], + ), + ), + // Server Tasks — Plex-only (`/activities` API has no + // Jellyfin equivalent), hide the button entirely on + // Jellyfin-only profiles so the chrome doesn't show + // a permanently empty popover. + if (PlatformDetector.isDesktop(context) && + context.select((p) => p.hasOnlinePlexServers)) + FocusableAction( + onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), + child: ServerActivitiesButton(key: _serverActivitiesButtonKey), + ), + // User menu — profiles + sign out + _buildUserMenuAction(context), + ], + ); + }, ), - ), + ], ), ); } @@ -1076,7 +1048,6 @@ class _DiscoverScreenState extends State final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final hubsSpanMultipleServers = _hubsSpanMultipleServers(); final browseHubs = _tvBrowseHubs; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: browseHubs, @@ -1110,14 +1081,7 @@ class _DiscoverScreenState extends State bottom: 0, child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildOverlaidAppBar()), if (_switchingProfile) const ProfileSwitchingOverlay(), ], ), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 0d3550f7..5fd0d2a1 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.dart'; import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/settings_builder.dart'; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_scaffold.dart'; import 'catalog_search_screen.dart'; @@ -331,75 +331,57 @@ class ExploreScreenState extends State Widget _buildTvToolbar(CatalogSourcesProvider sources) { final active = sources.activeSource; - final statusBarHeight = MediaQuery.paddingOf(context).top; - final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; - final foregroundColor = colorScheme.onSurface; + final foregroundColor = Theme.of(context).colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), - child: Row( - children: [ - const Spacer(), - FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, - onBack: _navigateToSidebar, - spacing: 4, - actions: [ - if (active != null && sources.connectedSources.length > 1) - FocusableAction( - debugLabel: 'ExploreSourceSwitcher', - onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), - child: _buildSourceSwitcher( - sources, - active, - textStyle: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), - anchorAlignment: AppMenuAnchorAlignment.end, - parentOwnsFocus: true, - ), - ), - if (active != null) - FocusableAction( - icon: Symbols.search_rounded, - iconColor: foregroundColor, - tooltip: t.common.search, - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), - ), + return ToolbarScrim( + child: Row( + children: [ + const Spacer(), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, + onBack: _navigateToSidebar, + spacing: 4, + actions: [ + if (active != null && sources.connectedSources.length > 1) FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - tooltip: t.common.refresh, - onPressed: () => unawaited(_explore.load()), + debugLabel: 'ExploreSourceSwitcher', + onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), + child: _buildSourceSwitcher( + sources, + active, + textStyle: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), + anchorAlignment: AppMenuAnchorAlignment.end, + parentOwnsFocus: true, + ), ), - ], - ), - ], - ), + if (active != null) + FocusableAction( + icon: Symbols.search_rounded, + iconColor: foregroundColor, + tooltip: t.common.search, + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), + ), + FocusableAction( + icon: Symbols.refresh_rounded, + iconColor: foregroundColor, + tooltip: t.common.refresh, + onPressed: () => unawaited(_explore.load()), + ), + ], + ), + ], ), ); } Widget _buildTvContent(List rowHubs, CatalogSourcesProvider sources) { final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: tvHubs, spotlightListenable: _spotlight, @@ -447,14 +429,7 @@ class ExploreScreenState extends State tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildTvToolbar(sources)), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildTvToolbar(sources)), ], ), ); diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index dd98e5c1..7502f9bc 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -31,6 +31,8 @@ import 'tabs/guide_tab.dart'; import 'tabs/recordings_tab.dart'; import 'tabs/whats_on_tab.dart'; +typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode}); + enum LiveTvTab { guide, whatsOn, recordings } class LiveTvScreen extends StatefulWidget { @@ -66,13 +68,15 @@ class _LiveTvScreenState extends State Set _favoriteKeys = {}; List _favoriteChannels = []; - /// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. - final Map _favoriteSourceByLiveServer = {}; - final Map _favoriteSourceByChannel = {}; - final Map _favoriteStoreByLiveServer = {}; - final Map _favoriteStoreByChannel = {}; + /// Favorite source URI, store key and persistence mode per Live TV server/DVR. + /// The source is built from machineIdentifier + EPG provider identifier. + final Map _favoriteScopeByLiveServer = {}; + final Map _liveServerKeyByChannel = {}; + + /// Store key per favorite source. A superset of the scope sources: it also + /// collects sources of fetched and toggled favorites that belong to other + /// servers sharing an account-scoped store. final Map _favoriteStoreBySource = {}; - final Map _favoriteModeByStore = {}; Future? _channelsLoadFuture; int _favoritesLoadGeneration = 0; Future? _favoritesLoadFuture; @@ -90,8 +94,13 @@ class _LiveTvScreenState extends State String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; + _FavoriteScope? _favoriteScopeForChannel(LiveTvChannel channel) { + final liveServerKey = _liveServerKeyByChannel[liveTvChannelScopeKey(channel)]; + return liveServerKey == null ? null : _favoriteScopeByLiveServer[liveServerKey]; + } + String _sourceForChannel(LiveTvChannel channel) { - return channel.favoriteSource ?? _favoriteSourceByChannel[liveTvChannelScopeKey(channel)] ?? ''; + return channel.favoriteSource ?? _favoriteScopeForChannel(channel)?.source ?? ''; } String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key); @@ -303,12 +312,9 @@ class _LiveTvScreenState extends State final allChannels = []; final seenChannels = {}; - final favoriteSourceByLiveServer = {}; - final favoriteSourceByChannel = {}; - final favoriteStoreByLiveServer = {}; - final favoriteStoreByChannel = {}; + final favoriteScopeByLiveServer = {}; + final liveServerKeyByChannel = {}; final favoriteStoreBySource = {}; - final favoriteModeByStore = {}; appLogger.d( 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', @@ -333,10 +339,12 @@ class _LiveTvScreenState extends State final sourceTitle = _sourceTitleForServerInfo(serverInfo); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - favoriteSourceByLiveServer[liveServerKey] = source; - favoriteStoreByLiveServer[liveServerKey] = storeKey; + favoriteScopeByLiveServer[liveServerKey] = ( + source: source, + storeKey: storeKey, + mode: liveTv.favoritePersistenceMode, + ); favoriteStoreBySource[source] = storeKey; - favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin @@ -355,9 +363,7 @@ class _LiveTvScreenState extends State ); final dedupKey = liveTvChannelScopeKey(scopedChannel); if (seenChannels.add(dedupKey)) { - final scopeKey = liveTvChannelScopeKey(scopedChannel); - favoriteSourceByChannel[scopeKey] = source; - favoriteStoreByChannel[scopeKey] = storeKey; + liveServerKeyByChannel[dedupKey] = liveServerKey; allChannels.add(scopedChannel); } } @@ -378,24 +384,15 @@ class _LiveTvScreenState extends State setState(() { _channels = allChannels; - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(favoriteSourceByLiveServer); - _favoriteSourceByChannel + ..addAll(favoriteScopeByLiveServer); + _liveServerKeyByChannel ..clear() - ..addAll(favoriteSourceByChannel); - _favoriteStoreByLiveServer - ..clear() - ..addAll(favoriteStoreByLiveServer); - _favoriteStoreByChannel - ..clear() - ..addAll(favoriteStoreByChannel); + ..addAll(liveServerKeyByChannel); _favoriteStoreBySource ..clear() ..addAll(favoriteStoreBySource); - _favoriteModeByStore - ..clear() - ..addAll(favoriteModeByStore); _isLoading = false; }); @@ -433,10 +430,8 @@ class _LiveTvScreenState extends State _favoritesLoaded = false; _favoritesWritable = false; final previousStoreBySource = Map.of(_favoriteStoreBySource); - final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); - final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); + final scopeByLiveServer = Map.of(_favoriteScopeByLiveServer); final storeBySource = Map.of(_favoriteStoreBySource); - final modeByStore = Map.of(_favoriteModeByStore); final merged = []; final successfulStores = {}; final failedStores = {}; @@ -448,12 +443,10 @@ class _LiveTvScreenState extends State final liveTv = client.liveTv; final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - storeByLiveServer[liveServerKey] = storeKey; - modeByStore[storeKey] = liveTv.favoritePersistenceMode; try { final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); - sourceByLiveServer[liveServerKey] = source; + scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode); storeBySource[source] = storeKey; if (successfulStores.contains(storeKey)) continue; @@ -482,18 +475,12 @@ class _LiveTvScreenState extends State if (!mounted || loadGeneration != _favoritesLoadGeneration) return; setState(() { - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(sourceByLiveServer); - _favoriteStoreByLiveServer - ..clear() - ..addAll(storeByLiveServer); + ..addAll(scopeByLiveServer); _favoriteStoreBySource ..clear() ..addAll(storeBySource); - _favoriteModeByStore - ..clear() - ..addAll(modeByStore); _favoriteChannels = merged; _refreshFavoriteKeys(); _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; @@ -515,8 +502,7 @@ class _LiveTvScreenState extends State _enqueueFavoriteMutation(() { final source = _sourceForChannel(channel); final favoriteKey = favoriteChannelKey(source, channel.key); - final scopeKey = liveTvChannelScopeKey(channel); - final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey]; + final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey; if (storeKey != null) _favoriteStoreBySource[source] = storeKey; setState(() { @@ -596,16 +582,13 @@ class _LiveTvScreenState extends State for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; - final liveServerKey = _liveServerScopeKey(serverInfo); - final storeKey = _favoriteStoreByLiveServer[liveServerKey]; - if (storeKey == null || !writtenStores.add(storeKey)) continue; - final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; - final source = _favoriteSourceByLiveServer[liveServerKey]; - if (source == null) continue; - final channels = switch (mode) { - FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const [], + final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)]; + if (scope == null || !writtenStores.add(scope.storeKey)) continue; + final storeChannels = byStore[scope.storeKey] ?? const []; + final channels = switch (scope.mode) { + FavoriteChannelPersistenceMode.sharedFullList => storeChannels, FavoriteChannelPersistenceMode.serverSlice => - (byStore[storeKey] ?? const []).where((favorite) => favorite.source == source).toList(), + storeChannels.where((favorite) => favorite.source == scope.source).toList(), }; writes.add(client.liveTv.setFavoriteChannels(channels)); } diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 76999c99..abc3be90 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -568,16 +568,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); final playbackContext = await playbackResolver.resolve( - metadata: metadata, - selectedMediaIndex: targetMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, + PlaybackInitializationOptions( + metadata: metadata, + selectedMediaIndex: targetMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + qualityPreset: targetQualityPreset, + selectedAudioStreamId: targetAudioStreamId, + preferredSubtitleTrack: initializationSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: _offlineLibraryMode, - qualityPreset: targetQualityPreset, - selectedAudioStreamId: targetAudioStreamId, - preferredSubtitleTrack: initializationSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; final result = playbackContext.result; diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 27a5738d..e0c24563 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -110,15 +110,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { database: context.read(), ); playbackContext = await playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: true, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (playbackContext.result.videoUrl == null) { throw PlaybackException(t.messages.fileInfoNotAvailable); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 030cecb1..282d2310 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1060,16 +1060,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin database: context.read(), ); _playbackDataFuture = playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, - preferredVersionSignature: widget.preferredVersionSignature, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + preferredVersionSignature: widget.preferredVersionSignature, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: false, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); // If MPV setup below throws before `_startPlayback` awaits this, // tell Dart we've "handled" the future so it's not reported as an diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index f3103e66..6db017af 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1646,6 +1646,20 @@ class DownloadManagerService { return true; } + /// Hand a prepared task to the native downloader, recording its id first so a + /// concurrent cancel can find it. Returns true if the download went inactive + /// while enqueueing and the task was dropped again. + Future _enqueuePreparedTask(String globalKey, Task task, String kind) async { + await _database.updateBgTaskId(globalKey, task.taskId); + final success = await FileDownloader().enqueue(task); + if (!success) throw Exception('Failed to enqueue $kind task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { + return true; + } + appLogger.i('Enqueued $kind task ${task.taskId} for $globalKey'); + return false; + } + /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. Future _prepareAndEnqueueDownload( @@ -1753,38 +1767,30 @@ class DownloadManagerService { if (_queueBlockedByStorageFailure) return true; final metadata = resolvedMetadata; final safBaseUri = _storageService.safBaseUri; + final DownloadTask task; + final String filePath; + final String? safRootUri; if (_storageService.isUsingSaf && safBaseUri != null) { - final safRootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); - if (safRootUri == null) { + final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); + if (rootUri == null) { throw StateError('Selected SAF root has no persisted permission'); } - await _replaceDownloadSafRootClaim(globalKey, safRootUri); + await _replaceDownloadSafRootClaim(globalKey, rootUri); // SAF mode: use UriDownloadTask (writes directly to content:// URI, // with no pause/resume support). - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [serverId, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId); - final safDirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components); if (safDirUri == null) { throw Exception('Failed to create SAF directory'); } - await _cleanupSafTargetFile(safDirUri, safFileName); + await _cleanupSafTargetFile(safDirUri, target.fileName); - final task = UriDownloadTask( + task = UriDownloadTask( url: resolution.videoUrl!, - filename: safFileName, + filename: target.fileName, directoryUri: Uri.parse(safDirUri), group: _downloadGroup, updates: Updates.statusAndProgress, @@ -1794,82 +1800,60 @@ class DownloadManagerService { metaData: globalKey, displayName: displayName, ); - - _pendingDownloadContext[globalKey] = _DownloadContext( - metadata: metadata, - queueItem: queueItem, - filePath: safDirUri, - extension: ext, - client: client, - showYear: showYear, - isSafMode: true, - safRootUri: safRootUri, - subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, - ); - - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue SAF download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); - return false; - } - - await _replaceDownloadSafRootClaim(globalKey, null); - - // Normal mode: use DownloadTask with pause/resume support. - String downloadFilePath; - if (metadata.isMovie) { - downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); - } else if (metadata.isEpisode) { - downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + filePath = safDirUri; + safRootUri = rootUri; } else { - downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + await _replaceDownloadSafRootClaim(globalKey, null); + + // Normal mode: use DownloadTask with pause/resume support. + final String downloadFilePath; + if (metadata.isMovie) { + downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); + } else if (metadata.isEpisode) { + downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + } else { + downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + } + + // Clean up partial files from previous attempts to prevent + // background_downloader from creating numbered copies (File (1).mp4). + await Future.wait([ + _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), + _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), + ]); + + await File(downloadFilePath).parent.create(recursive: true); + + task = DownloadTask( + url: resolution.videoUrl!, + filename: path.basename(downloadFilePath), + directory: path.dirname(downloadFilePath), + baseDirectory: BaseDirectory.root, + group: _downloadGroup, + updates: Updates.statusAndProgress, + requiresWiFi: requiresWiFi, + retries: _nativeRetries, + allowPause: true, + metaData: globalKey, + displayName: displayName, + ); + filePath = downloadFilePath; + safRootUri = null; } - // Clean up partial files from previous attempts to prevent - // background_downloader from creating numbered copies (File (1).mp4). - await Future.wait([ - _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), - _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), - ]); - - await File(downloadFilePath).parent.create(recursive: true); - - final task = DownloadTask( - url: resolution.videoUrl!, - filename: path.basename(downloadFilePath), - directory: path.dirname(downloadFilePath), - baseDirectory: BaseDirectory.root, - group: _downloadGroup, - updates: Updates.statusAndProgress, - requiresWiFi: requiresWiFi, - retries: _nativeRetries, - allowPause: true, - metaData: globalKey, - displayName: displayName, - ); - _pendingDownloadContext[globalKey] = _DownloadContext( metadata: metadata, queueItem: queueItem, - filePath: downloadFilePath, + filePath: filePath, extension: ext, client: client, showYear: showYear, + isSafMode: safRootUri != null, + safRootUri: safRootUri, subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, ); - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued download task ${task.taskId} for $globalKey'); - return false; + return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download'); }); if (becameInactive) return true; return true; @@ -2417,23 +2401,12 @@ class DownloadManagerService { } Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [metadata.serverId!, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId); - final dirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components); if (dirUri == null) return null; - final child = await _safStorage.getChild(dirUri, [safFileName]); + final child = await _safStorage.getChild(dirUri, [target.fileName]); return child?.uri; } diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index a2deca0a..e7adb362 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; @@ -477,6 +478,28 @@ class DownloadStorageService { /// Get the extension-less episode filename used for SAF lookups. String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); + /// Directory components and file name of a SAF download target. Used by both + /// the enqueue path that writes the file and the completion path that looks + /// it back up, so the two stay on the same layout. [serverId] is only read + /// for kinds without a dedicated folder scheme. + ({List components, String fileName}) safTarget( + MediaItem metadata, + String extension, { + int? showYear, + required String? serverId, + }) { + if (metadata.isMovie) { + return (components: getMovieSafPathComponents(metadata), fileName: getMovieSafFileName(metadata, extension)); + } + if (metadata.isEpisode) { + return ( + components: getEpisodeSafPathComponents(metadata, showYear: showYear), + fileName: getEpisodeSafFileName(metadata, extension), + ); + } + return (components: [serverId!, metadata.id], fileName: 'video.$extension'); + } + bool isSafUri(String storedPath) { return storedPath.startsWith('content://'); } diff --git a/lib/services/music/music_source_resolver.dart b/lib/services/music/music_source_resolver.dart index 764a1b48..6d566e0f 100644 --- a/lib/services/music/music_source_resolver.dart +++ b/lib/services/music/music_source_resolver.dart @@ -64,17 +64,19 @@ class ServerMusicSourceResolver implements MusicSourceResolver { Future resolve(MediaItem track) async { final settings = await SettingsService.getInstance(); final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve( - metadata: track, - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: track, + selectedMediaIndex: 0, + // Video-shaped preset is ignored for tracks; `original` also keeps the + // resolver's downloaded-copy preference on. + qualityPreset: TranscodeQualityPreset.original, + audioQualityPreset: settings.read(SettingsService.musicQualityPreset), + // Plex music transcode requires both session ids; fresh per track so + // concurrent gapless arming never reuses a live transcode session. + sessionIdentifier: generateSessionIdentifier(), + transcodeSessionId: generateSessionIdentifier(), + ), offlineLibraryMode: false, - // Video-shaped preset is ignored for tracks; `original` also keeps the - // resolver's downloaded-copy preference on. - qualityPreset: TranscodeQualityPreset.original, - audioQualityPreset: settings.read(SettingsService.musicQualityPreset), - // Plex music transcode requires both session ids; fresh per track so - // concurrent gapless arming never reuses a live transcode session. - sessionIdentifier: generateSessionIdentifier(), - transcodeSessionId: generateSessionIdentifier(), ); final result = context.result; diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 9a600db9..f4125a3c 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -8,8 +8,6 @@ import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; import '../utils/global_key_utils.dart'; @@ -110,19 +108,11 @@ class PlaybackInitializationService { /// /// Downloaded/offline path: when [preferOffline] finds a downloaded copy, /// builds from cached [MediaSourceInfo] and local sidecars immediately. - Future getPlaybackData({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + Future getPlaybackData( + PlaybackInitializationOptions options, { bool preferOffline = false, - TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, }) async { + final metadata = options.metadata; final serverId = metadata.serverId ?? client?.serverId; DownloadedVideoSource? offlineSource; @@ -130,8 +120,8 @@ class PlaybackInitializationService { offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), metadata.id, - mediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + mediaIndex: options.selectedMediaIndex, + selectedMediaSourceId: options.selectedMediaSourceId, // With no client there is nothing to stream from, so any downloaded // version beats failing. With a client the strict match must stand: // an explicitly requested non-downloaded version streams from the @@ -156,20 +146,7 @@ class PlaybackInitializationService { PlaybackInitializationResult result; try { - result = await client!.getPlaybackInitialization( - PlaybackInitializationOptions( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, - ), - ); + result = await client!.getPlaybackInitialization(options); } catch (e) { rethrow; } diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index dabd4b84..52253f0b 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -1,11 +1,7 @@ import '../database/app_database.dart'; import '../media/ids.dart'; import '../media/media_backend.dart'; -import '../media/media_item.dart'; import '../media/media_server_client.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/transcode_quality_preset.dart'; -import '../mpv/mpv.dart'; import 'multi_server_manager.dart'; import 'playback_context.dart'; import 'playback_initialization_service.dart'; @@ -17,40 +13,20 @@ class PlaybackSourceResolver { const PlaybackSourceResolver({required this.serverManager, required this.database}); /// [preferOffline] overrides the default downloaded-copy preference - /// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for - /// flows that must stay on the server stream, e.g. a transcode restart. - /// - /// [audioQualityPreset] is the music transcode preset, consulted by the - /// backends only for [MediaKind.track] items ([qualityPreset] is - /// video-shaped and ignored for tracks). - Future resolve({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + /// (`offlineLibraryMode || options.qualityPreset.isOriginal`, so an omitted + /// preset keeps it on). Pass false for flows that must stay on the server + /// stream, e.g. a transcode restart. + Future resolve( + PlaybackInitializationOptions options, { required bool offlineLibraryMode, - required TranscodeQualityPreset qualityPreset, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, bool? preferOffline, }) async { + final metadata = options.metadata; final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode); final service = PlaybackInitializationService(client: reportingClient, database: database); final result = await service.getPlaybackData( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal), - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, + options, + preferOffline: preferOffline ?? (offlineLibraryMode || options.qualityPreset.isOriginal), ); final sourceKind = result.usesLocalMedia @@ -75,7 +51,7 @@ class PlaybackSourceResolver { streamHeaders: _streamHeaders( client: reportingClient, sourceKind: sourceKind, - sessionIdentifier: sessionIdentifier, + sessionIdentifier: options.sessionIdentifier, ), ); } diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 33eadb1e..f36c7039 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -183,31 +183,19 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, Future> getEpgChannels({String? lineup}) async { List parseChannels(MediaServerResponse response) { final container = _getMediaContainer(response); - if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { - appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + if (container == null || (container['Channel'] == null && container['Metadata'] == null)) { + appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); + return []; } - if (container != null && container['Channel'] != null) { - return (container['Channel'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); + final rawChannels = container['Channel']; + if (rawChannels is List && rawChannels.isNotEmpty) { + appLogger.d('EPG channel sample: ${rawChannels.first}'); } - if (container != null && container['Metadata'] != null) { - return (container['Metadata'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); - } - appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); - return []; + return _extractContainerList( + response, + const ['Channel', 'Metadata'], + (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName), + ).where((ch) => ch.key.isNotEmpty).toList(); } final allChannels = []; @@ -545,11 +533,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (container == null) return null; final containerStatus = container['status']; - final statusInt = containerStatus is num - ? containerStatus.toInt() - : containerStatus is String - ? int.tryParse(containerStatus) - : null; + final statusInt = flexibleInt(containerStatus); if (statusInt != null && statusInt != 0 && statusInt != 200) { final msg = container['message'] ?? t.liveTv.unknownError; appLogger.w('Tune channel error: $msg (status: $containerStatus)'); @@ -579,14 +563,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (op is Map) { if (op['Metadata'] case [final Map firstMetadata, ...]) { if (firstMetadata['Media'] case [final Map firstMedia, ...]) { - final rawBeginsAt = firstMedia['beginsAt']; - - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; - + beginsAt = flexibleInt(firstMedia['beginsAt']); appLogger.d('beginsAt=$beginsAt'); } } @@ -652,12 +629,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, if (media is List && media.isNotEmpty) { final firstMedia = media.first; if (firstMedia is Map) { - final rawBeginsAt = firstMedia['beginsAt']; - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; + beginsAt = flexibleInt(firstMedia['beginsAt']); } } } diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 7c0f8792..a3596715 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -29,6 +29,9 @@ class DesktopWindowPadding { /// Right padding for mobile devices to prevent actions from being too close to edge static const double mobileRight = 6.0; + + /// Left padding for macOS reflecting the current fullscreen state + static double get macOSLeftCurrent => FullscreenStateManager().isFullscreen ? macOSLeftFullscreen : macOSLeft; } /// Helper class for adjusting app bar widgets to account for desktop window controls @@ -60,38 +63,18 @@ class DesktopAppBarHelper { } if (context != null && SideNavigationScope.isPresent(context)) { - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: leading, - ); - } - return leading; + return includeGestureDetector ? wrapWithGestureDetector(leading, opaque: true) : leading; } return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - final paddedWidget = Padding( - padding: .only(left: leftPadding), + padding: .only(left: DesktopWindowPadding.macOSLeftCurrent), child: leading, ); - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: paddedWidget, - ); - } - - return paddedWidget; + return includeGestureDetector ? wrapWithGestureDetector(paddedWidget, opaque: true) : paddedWidget; }, ); } @@ -102,12 +85,7 @@ class DesktopAppBarHelper { return flexibleSpace; } - return GestureDetector( - behavior: HitTestBehavior.translucent, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: flexibleSpace, - ); + return wrapWithGestureDetector(flexibleSpace); } /// Calculates the leading width for SliverAppBar to account for macOS traffic lights @@ -121,9 +99,7 @@ class DesktopAppBarHelper { return null; } - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - return leftPadding + kToolbarHeight; + return DesktopWindowPadding.macOSLeftCurrent + kToolbarHeight; } /// Wraps a widget with GestureDetector on macOS to prevent window dragging @@ -175,10 +151,8 @@ class DesktopTitleBarPadding extends StatelessWidget { return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; // In fullscreen, use minimal padding since traffic lights auto-hide - final left = - leftPadding ?? (isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft); + final left = leftPadding ?? DesktopWindowPadding.macOSLeftCurrent; final right = rightPadding ?? 0.0; if (left == 0.0 && right == 0.0) { diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index fc6cfb13..9e0a0a4e 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -14,7 +14,7 @@ import '../services/settings_service.dart'; import '../utils/global_key_utils.dart'; import 'catalog_navigation_helper.dart'; import 'music_navigation.dart'; -import 'plex_library_section_helpers.dart'; +import 'plex_library_section_utils.dart'; import 'video_player_navigation.dart'; /// Result of media navigation indicating what action was taken @@ -192,11 +192,13 @@ Future navigateToMediaItem( ); // Handle library section items (shared whole-library entries) — Plex-only; - // [PlexLibrarySection.isLibrarySection] reads the stashed `key` from `raw`. - if (mi.isLibrarySection) { - final sectionKey = mi.librarySectionKey; - if (sectionKey != null && mi.serverId != null) { - final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey); + // `PlexMappers` stashes the section path in `raw['key']`. Jellyfin "views" + // never appear inside a [MediaItem], so the gate never fires for them. + final rawKey = mi.raw?['key']; + if (rawKey is String && rawKey.startsWith('/library/sections/')) { + final sectionId = plexLibrarySectionIdFromString(rawKey); + if (sectionId != null && mi.serverId != null) { + final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), '$sectionId'); MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey); return MediaNavigationResult.librarySelected; } diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 0162f011..5daf4001 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -162,48 +162,19 @@ class MediaServerHttpClient { }) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); /// Fetch raw bytes (e.g. images, BIF files, subtitles). - Future getBytes( - String url, { - Map? headers, - Duration? timeout, - AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } - - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - final sw = Stopwatch()..start(); - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'GET ${uri.path} connect', - abort: requestAbort, - ); - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: 'GET ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds); - return bytes; - } catch (e) { - requestAbort.abort(); - sw.stop(); - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + Future getBytes(String url, {Map? headers, Duration? timeout, AbortController? abort}) { + return _perform( + 'GET', + url, + headers: headers, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + return bytes; + }, + ); } /// Stream-download a URL directly into a file. @@ -213,64 +184,48 @@ class MediaServerHttpClient { Map? headers, Duration? timeout, AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } + }) { + final tempFile = File('$filePath.download'); + return _perform( + 'GET', + url, + label: 'download', + headers: headers, + timeout: timeout, + abort: abort, + // Also clears a temp file left by an earlier attempt when this one never + // got past connect. + onError: () async { + if (await tempFile.exists()) { + try { + await tempFile.delete(); + } catch (_) {} + } + }, + consume: (streamed, scope) async { + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await streamed.stream.drain(); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + requestUri: scope.uri, + message: 'HTTP ${streamed.statusCode}', + ); + } - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'download ${uri.path} connect', - abort: requestAbort, - ); - - if (streamed.statusCode < 200 || streamed.statusCode >= 300) { - await streamed.stream.drain(); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - requestUri: uri, - message: 'HTTP ${streamed.statusCode}', - ); - } - - final file = File(filePath); - await file.parent.create(recursive: true); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) await tempFile.delete(); - final sink = tempFile.openWrite(); - try { - await _withAbortOnTimeout( - streamed.stream.pipe(sink), - timeout ?? receiveTimeout, - operation: 'download ${uri.path} receive', - abort: requestAbort, - ); - } finally { - await sink.close(); - } - if (await file.exists()) await file.delete(); - await tempFile.rename(filePath); - } catch (e) { - requestAbort.abort(); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) { + final file = File(filePath); + await file.parent.create(recursive: true); + if (await tempFile.exists()) await tempFile.delete(); + final sink = tempFile.openWrite(); try { - await tempFile.delete(); - } catch (_) {} - } - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + await scope.receive(streamed.stream.pipe(sink)); + } finally { + await sink.close(); + } + if (await file.exists()) await file.delete(); + await tempFile.rename(filePath); + }, + ); } void close() { @@ -297,69 +252,90 @@ class MediaServerHttpClient { Object? body, Duration? timeout, AbortController? abort, + }) { + return _perform( + method, + path, + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final effectiveUri = switch (streamed) { + http.BaseResponseWithUrl(:final url) => url, + _ => scope.uri, + }; + + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + + dynamic data; + try { + data = await _decodeBody(bytes, streamed.headers); + } catch (e) { + final body = await _decodeTextBody(bytes); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + responseData: body, + requestUri: scope.uri, + message: 'Failed to decode response body: $e', + ); + } + return MediaServerResponse( + statusCode: streamed.statusCode, + data: data, + headers: streamed.headers, + requestUri: scope.uri, + effectiveUri: effectiveUri, + ); + }, + ); + } + + /// Run one request: closing guard, abort registration, connect phase and + /// failure wrapping. [consume] reads the body through its scope, which + /// carries the same timeout and abort wiring into the receive phase; + /// [onError] runs after the abort and before the failure is wrapped. Every + /// exit path deregisters the request from [_activeAborts]. + Future _perform( + String method, + String url, { + String? label, + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + Future Function()? onError, + required Future Function(http.StreamedResponse streamed, _RequestScope scope) consume, }) async { if (_closing) { throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); } - final uri = _isAbsoluteUrl(path) - ? _appendQuery(Uri.parse(path), queryParameters) - : _buildUri(path, queryParameters); - - final mergedHeaders = {...defaultHeaders, ...?headers}; + final uri = _resolveUri(url, queryParameters); + final operation = label ?? method; final requestAbort = AbortController(); _activeAborts.add(requestAbort); final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll(mergedHeaders); + request.headers.addAll({...defaultHeaders, ...?headers}); _setBody(request, body); - final sw = Stopwatch()..start(); + final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout); try { final streamed = await _withAbortOnTimeout( _client.send(request), timeout ?? connectTimeout, - operation: '$method ${uri.path} connect', + operation: '$operation ${uri.path} connect', abort: requestAbort, ); - final effectiveUri = switch (streamed) { - http.BaseResponseWithUrl(:final url) => url, - _ => uri, - }; - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: '$method ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); - - dynamic data; - try { - data = await _decodeBody(bytes, streamed.headers); - } catch (e) { - final body = await _decodeTextBody(bytes); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - responseData: body, - requestUri: uri, - message: 'Failed to decode response body: $e', - ); - } - return MediaServerResponse( - statusCode: streamed.statusCode, - data: data, - headers: streamed.headers, - requestUri: uri, - effectiveUri: effectiveUri, - ); + return await consume(streamed, scope); } catch (e) { requestAbort.abort(); - sw.stop(); + await onError?.call(); throw MediaServerHttpException.from(e, uri: uri); } finally { _activeAborts.remove(requestAbort); @@ -410,6 +386,11 @@ class MediaServerHttpClient { return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters); } + /// Resolve a request target: absolute URLs keep their own host and query, + /// relative paths go through [baseUrl]. + Uri _resolveUri(String url, Map? queryParameters) => + _isAbsoluteUrl(url) ? _appendQuery(Uri.parse(url), queryParameters) : _buildUri(url, queryParameters); + /// Append query parameters to an already-parsed URI. Uri _appendQuery(Uri uri, Map? queryParameters) { if (queryParameters == null || queryParameters.isEmpty) return uri; @@ -481,6 +462,28 @@ class MediaServerHttpClient { } } +/// The live request handed to a [MediaServerHttpClient._perform] body handler. +/// Its stopwatch starts with the connect phase, so [logResponse] reports the +/// full round trip regardless of how the body was read. +class _RequestScope { + _RequestScope(this._owner, this.uri, this._operation, this._abort, this._receiveTimeout); + + final MediaServerHttpClient _owner; + final Uri uri; + final String _operation; + final AbortController _abort; + final Duration _receiveTimeout; + final Stopwatch _sw = Stopwatch()..start(); + + Future receive(Future future) => + _owner._withAbortOnTimeout(future, _receiveTimeout, operation: '$_operation ${uri.path} receive', abort: _abort); + + void logResponse(int statusCode) { + _sw.stop(); + _owner._logResponse(_operation, uri, statusCode, _sw.elapsedMilliseconds); + } +} + /// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks, /// log uploads, image fetches, etc). No base URL or default Plex headers. final httpClient = MediaServerHttpClient(); diff --git a/lib/utils/plex_library_section_helpers.dart b/lib/utils/plex_library_section_helpers.dart deleted file mode 100644 index fec97cce..00000000 --- a/lib/utils/plex_library_section_helpers.dart +++ /dev/null @@ -1,31 +0,0 @@ -import '../media/media_item.dart'; - -/// Plex-only helpers for navigating to a "library section" hub entry. -/// -/// Plex's home/discover hubs occasionally surface library-section rows -/// (`/library/sections/{id}/all`) alongside individual items; the -/// `PlexMappers` adapter stashes the section key in [MediaItem.raw] under -/// `'key'` so navigation code can detect and route to the library screen -/// instead of the media-detail screen. -/// -/// Jellyfin's analogue is the dedicated `MediaLibrary` shape — Jellyfin -/// "views" never appear inside a [MediaItem], so these helpers correctly -/// return `false`/`null` for any Jellyfin item. -extension PlexLibrarySection on MediaItem { - /// Whether this item represents a Plex library section (shared - /// whole-library entry, not a media item). - bool get isLibrarySection { - final key = raw?['key']; - return key is String && key.startsWith('/library/sections/'); - } - - /// Extract the library section id from the stashed Plex `raw['key']`. - /// Returns `null` for non-section items or items without a parsable id. - String? get librarySectionKey { - if (!isLibrarySection) return null; - final key = raw?['key'] as String?; - if (key == null) return null; - final match = RegExp(r'/library/sections/(\d+)').firstMatch(key); - return match?.group(1); - } -} diff --git a/lib/widgets/toolbar_scrim.dart b/lib/widgets/toolbar_scrim.dart new file mode 100644 index 00000000..50214105 --- /dev/null +++ b/lib/widgets/toolbar_scrim.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'rasterized_gradient.dart'; + +/// Top-edge fade behind a toolbar that floats over content, keeping its +/// glyphs legible against artwork without a solid chrome bar. +/// +/// The fade is pure black on dark schemes — a tinted surface reads as haze +/// over backdrop artwork — and the scheme surface otherwise. [child] is laid +/// out below the status bar with the standard chrome insets. +class ToolbarScrim extends StatelessWidget { + const ToolbarScrim({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final statusBarHeight = MediaQuery.paddingOf(context).top; + final colorScheme = Theme.of(context).colorScheme; + final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; + return RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.5), + overlayColor.withValues(alpha: 0.3), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.6, 1.0], + ), + child: Padding( + padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), + child: child, + ), + ); + } +} diff --git a/lib/widgets/tv_spotlight_scaffold.dart b/lib/widgets/tv_spotlight_scaffold.dart index 80b775ad..b6da0798 100644 --- a/lib/widgets/tv_spotlight_scaffold.dart +++ b/lib/widgets/tv_spotlight_scaffold.dart @@ -142,3 +142,27 @@ class TvSpotlightScaffold extends StatelessWidget { ); } } + +/// Pins a toolbar to the top of the viewport across the full bleed width, +/// sliding with the sidebar so it stays put while the content box translates. +/// +/// Excluded from default focus traversal so that initial/tab-switch focus +/// lands on content (hero/rails) rather than the toolbar; its buttons stay +/// reachable via explicit UP from the content. Reads the offset aspect from +/// its own element, so a sidebar flip rebuilds only this overlay. +class TvToolbarOverlay extends StatelessWidget { + const TvToolbarOverlay({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); + return SideNavigationBleedBuilder( + targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), + child: ExcludeFocusTraversal(child: child), + builder: (context, animatedBleed, child) => + Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), + ); + } +} diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index afe63949..67ad2312 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -7,7 +7,7 @@ import '../../../utils/track_label_builder.dart'; import '../../../widgets/focusable_list_tile.dart'; class TrackSelectionHelper { - static Widget buildOffTile({ + static Widget buildOffTile({ required BuildContext context, required bool isSelected, required VoidCallback onTap, @@ -30,7 +30,7 @@ class TrackSelectionHelper { ); } - static Widget buildTrackTile({ + static Widget buildTrackTile({ required BuildContext context, required TrackLabel label, required bool isSelected, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 56af4738..80748da2 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -155,7 +155,7 @@ class _SourceAudioColumn extends StatelessWidget { initialIndex: selectedIndex, itemBuilder: (context, index, scope) { final track = tracks[index]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, key: scope.keyFor(index), label: track.label, @@ -196,7 +196,7 @@ class _SourceSubtitleColumn extends StatelessWidget { footer: _buildSubtitleSearchFooter(context, trackControlsState), itemBuilder: (context, index, scope) { if (index == 0) { - return TrackSelectionHelper.buildOffTile( + return TrackSelectionHelper.buildOffTile( context: context, key: scope.keyFor(index), isSelected: selectedChoice.isOff, @@ -207,7 +207,7 @@ class _SourceSubtitleColumn extends StatelessWidget { } final track = tracks[index - 1]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: track.labelForIndex(index - 1), isSelected: track.id == selectedId, @@ -264,7 +264,7 @@ class _AudioColumn extends StatelessWidget { channels: track.channelsCount, index: index, ); - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, key: scope.keyFor(index), label: label, @@ -324,7 +324,7 @@ class _SubtitleColumn extends StatelessWidget { footer: _buildSubtitleSearchFooter(context, trackControlsState), itemBuilder: (context, index, scope) { if (index == 0) { - return TrackSelectionHelper.buildOffTile( + return TrackSelectionHelper.buildOffTile( context: context, key: scope.keyFor(index), isSelected: isOffSelected, @@ -357,7 +357,7 @@ class _SubtitleColumn extends StatelessWidget { if (trackIndex >= tracks.length) { final sourceIndex = trackIndex - tracks.length; final sourceTrack = unloadedSourceSidecars[sourceIndex]; - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: sourceTrack.labelForIndex(trackIndex), isSelected: false, @@ -387,7 +387,7 @@ class _SubtitleColumn extends StatelessWidget { } } - return TrackSelectionHelper.buildTrackTile( + return TrackSelectionHelper.buildTrackTile( context: context, label: label, isSelected: isPrimary, diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 0af80d82..ba655aec 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -65,13 +65,15 @@ void main() { await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -94,13 +96,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'track-1', - backend: MediaBackend.plex, - kind: MediaKind.track, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'track-1', + backend: MediaBackend.plex, + kind: MediaKind.track, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -121,13 +125,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -153,13 +159,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 1, ), - selectedMediaIndex: 1, preferOffline: true, ); @@ -186,13 +194,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, ); expect(result.isOffline, isTrue); @@ -213,14 +223,16 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', ); expect(result.isOffline, isTrue); @@ -243,14 +255,16 @@ void main() { final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', preferOffline: true, ); @@ -297,13 +311,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'item-1', - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - serverId: ServerId('jf-machine'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: ServerId('jf-machine'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -325,13 +341,15 @@ void main() { await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart index c316ae00..5fa9fd99 100644 --- a/test/services/playback_source_resolver_test.dart +++ b/test/services/playback_source_resolver_test.dart @@ -57,10 +57,12 @@ void main() { manager.debugRegisterClientForTesting(client, online: false); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, ); expect(context.result.videoUrl, 'https://example.com/video.mp4'); @@ -80,11 +82,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect); @@ -104,11 +108,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect);