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.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent 83f4e2a263
commit c68ffe9ed0
23 changed files with 699 additions and 840 deletions
+12 -39
View File
@@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
var anchor = current; var anchor = current;
// Bounded so a pathological all-same-file queue cannot spin. // Bounded so a pathological all-same-file queue cannot spin.
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemAfter(anchor); final result = await _itemAtOffset(anchor, 1);
final candidate = result.item; final candidate = result.item;
if (result.status != QueueNavigationStatus.found || candidate == null) { if (result.status != QueueNavigationStatus.found || candidate == null) {
return result; return result;
@@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
final current = _loadedItems[indexResult.index!]; final current = _loadedItems[indexResult.index!];
MediaItem candidate = current; MediaItem candidate = current;
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemBefore(candidate); final result = await _itemAtOffset(candidate, -1);
final before = result.item; final before = result.item;
if (result.status != QueueNavigationStatus.found || before == null) { if (result.status != QueueNavigationStatus.found || before == null) {
return result; return result;
@@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
// Collapse to the first episode of the candidate's same-file group. // Collapse to the first episode of the candidate's same-file group.
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemBefore(candidate); final result = await _itemAtOffset(candidate, -1);
final before = result.item; final before = result.item;
if (result.status == QueueNavigationStatus.failed) return result; if (result.status == QueueNavigationStatus.failed) return result;
if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) {
@@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
return QueueNavigationResult.found(candidate); 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 /// window when needed. The centered response proves whether [anchor] is at
/// the global boundary; a window-local index is never compared with the /// the global boundary; a window-local index is never compared with the
/// queue's global item count. /// queue's global item count.
Future<QueueNavigationResult> _itemAfter(MediaItem anchor) async { Future<QueueNavigationResult> _itemAtOffset(MediaItem anchor, int delta) async {
final anchorId = playQueueItemIdFor(anchor); final anchorId = playQueueItemIdFor(anchor);
if (anchorId == null) return const QueueNavigationResult.unavailable(); if (anchorId == null) return const QueueNavigationResult.unavailable();
var anchorIndex = _findLoadedIndex(anchorId); var anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); if (anchorIndex == -1) return const QueueNavigationResult.unavailable();
if (anchorIndex + 1 < _loadedItems.length) { var target = anchorIndex + delta;
return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); if (target >= 0 && target < _loadedItems.length) {
return QueueNavigationResult.found(_loadedItems[target]);
} }
// Local queues are fully resident, so their window edge is the queue edge. // 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 // 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)) { if (!await _loadServerWindow(anchorId)) {
return const QueueNavigationResult.failed(); return const QueueNavigationResult.failed();
} }
anchorIndex = _findLoadedIndex(anchorId); anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.failed(); if (anchorIndex == -1) return const QueueNavigationResult.failed();
return anchorIndex + 1 < _loadedItems.length target = anchorIndex + delta;
? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) return target >= 0 && target < _loadedItems.length
: const QueueNavigationResult.boundary(); ? QueueNavigationResult.found(_loadedItems[target])
}
/// The queue item immediately before [anchor], extending a server-backed
/// window when needed.
Future<QueueNavigationResult> _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])
: const QueueNavigationResult.boundary(); : const QueueNavigationResult.boundary();
} }
+113 -149
View File
@@ -25,7 +25,7 @@ import '../utils/media_image_helper.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../widgets/cycling_media_backdrop.dart'; import '../widgets/cycling_media_backdrop.dart';
import '../widgets/optimized_media_image.dart' show blurArtwork; 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/discover_provider.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../providers/watch_state_store.dart'; import '../providers/watch_state_store.dart';
@@ -741,153 +741,125 @@ class _DiscoverScreenState extends State<DiscoverScreen>
} }
Widget _buildOverlaidAppBar() { Widget _buildOverlaidAppBar() {
final statusBarHeight = MediaQuery.paddingOf(context).top;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
final foregroundColor = colorScheme.onSurface; final foregroundColor = colorScheme.onSurface;
return RasterizedGradient( return ToolbarScrim(
gradient: LinearGradient( child: Row(
begin: Alignment.topCenter, children: [
end: Alignment.bottomCenter, if (!PlatformDetector.isTV())
colors: [ Text(
overlayColor.withValues(alpha: 0.7), t.discover.title,
overlayColor.withValues(alpha: 0.5), style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold),
overlayColor.withValues(alpha: 0.3), ),
Colors.transparent, const Spacer(),
], Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
stops: const [0.0, 0.3, 0.6, 1.0], builder: (context, watchTogether, companionRemote, _) {
), final isDesktop = PlatformDetector.shouldActAsRemoteHost(context);
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<WatchTogetherProvider, CompanionRemoteProvider>(
builder: (context, watchTogether, companionRemote, _) {
final isDesktop = PlatformDetector.shouldActAsRemoteHost(context);
return FocusableActionBar( return FocusableActionBar(
key: _actionBarKey, key: _actionBarKey,
onNavigateLeft: _navigateToSidebar, onNavigateLeft: _navigateToSidebar,
onNavigateDown: _focusContentFromAppBar, onNavigateDown: _focusContentFromAppBar,
actions: [ actions: [
FocusableAction( FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load),
icon: Symbols.refresh_rounded, // Watch Together
iconColor: foregroundColor, FocusableAction(
onPressed: _discover.load, onPressed: () =>
), Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
// Watch Together child: Stack(
FocusableAction( children: [
onPressed: () => IconButton(
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), icon: AppIcon(
child: Stack( Symbols.group_rounded,
children: [ fill: watchTogether.isInSession ? 1 : 0,
IconButton( color: watchTogether.isInSession ? colorScheme.primary : foregroundColor,
icon: AppIcon( ),
Symbols.group_rounded, onPressed: () =>
fill: watchTogether.isInSession ? 1 : 0, Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, 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, 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( // Server Tasks — Plex-only (`/activities` API has no
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), // Jellyfin equivalent), hide the button entirely on
decoration: BoxDecoration( // Jellyfin-only profiles so the chrome doesn't show
color: colorScheme.primary, // a permanently empty popover.
borderRadius: const BorderRadius.all(Radius.circular(8)), if (PlatformDetector.isDesktop(context) &&
), context.select<MultiServerProvider, bool>((p) => p.hasOnlinePlexServers))
child: Text( FocusableAction(
'${watchTogether.participantCount}', onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(),
style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), child: ServerActivitiesButton(key: _serverActivitiesButtonKey),
), ),
), // User menu — profiles + sign out
), _buildUserMenuAction(context),
], ],
), );
), },
// 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<MultiServerProvider, bool>((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<DiscoverScreen>
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
final hubsSpanMultipleServers = _hubsSpanMultipleServers(); final hubsSpanMultipleServers = _hubsSpanMultipleServers();
final browseHubs = _tvBrowseHubs; final browseHubs = _tvBrowseHubs;
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
return TvSpotlightScaffold( return TvSpotlightScaffold(
hubs: browseHubs, hubs: browseHubs,
@@ -1110,14 +1081,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bottom: 0, bottom: 0,
child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers),
), ),
Builder( TvToolbarOverlay(child: _buildOverlaidAppBar()),
builder: (context) => SideNavigationBleedBuilder(
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
),
),
if (_switchingProfile) const ProfileSwitchingOverlay(), if (_switchingProfile) const ProfileSwitchingOverlay(),
], ],
), ),
+44 -69
View File
@@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/focusable_popup_menu_button.dart';
import '../widgets/settings_builder.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_browse_rail.dart';
import '../widgets/tv_spotlight_scaffold.dart'; import '../widgets/tv_spotlight_scaffold.dart';
import 'catalog_search_screen.dart'; import 'catalog_search_screen.dart';
@@ -331,75 +331,57 @@ class ExploreScreenState extends State<ExploreScreen>
Widget _buildTvToolbar(CatalogSourcesProvider sources) { Widget _buildTvToolbar(CatalogSourcesProvider sources) {
final active = sources.activeSource; final active = sources.activeSource;
final statusBarHeight = MediaQuery.paddingOf(context).top; final foregroundColor = Theme.of(context).colorScheme.onSurface;
final colorScheme = Theme.of(context).colorScheme;
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
final foregroundColor = colorScheme.onSurface;
return RasterizedGradient( return ToolbarScrim(
gradient: LinearGradient( child: Row(
begin: Alignment.topCenter, children: [
end: Alignment.bottomCenter, const Spacer(),
colors: [ FocusableActionBar(
overlayColor.withValues(alpha: 0.7), key: _actionBarKey,
overlayColor.withValues(alpha: 0.5), onNavigateLeft: _navigateToSidebar,
overlayColor.withValues(alpha: 0.3), onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus,
Colors.transparent, onBack: _navigateToSidebar,
], spacing: 4,
stops: const [0.0, 0.3, 0.6, 1.0], actions: [
), if (active != null && sources.connectedSources.length > 1)
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<void>(builder: (_) => CatalogSearchScreen(source: active))),
),
FocusableAction( FocusableAction(
icon: Symbols.refresh_rounded, debugLabel: 'ExploreSourceSwitcher',
iconColor: foregroundColor, onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true),
tooltip: t.common.refresh, child: _buildSourceSwitcher(
onPressed: () => unawaited(_explore.load()), 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<void>(builder: (_) => CatalogSearchScreen(source: active))),
),
FocusableAction(
icon: Symbols.refresh_rounded,
iconColor: foregroundColor,
tooltip: t.common.refresh,
onPressed: () => unawaited(_explore.load()),
),
],
),
],
), ),
); );
} }
Widget _buildTvContent(List<ExploreRowHub> rowHubs, CatalogSourcesProvider sources) { Widget _buildTvContent(List<ExploreRowHub> rowHubs, CatalogSourcesProvider sources) {
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
return TvSpotlightScaffold( return TvSpotlightScaffold(
hubs: tvHubs, hubs: tvHubs,
spotlightListenable: _spotlight, spotlightListenable: _spotlight,
@@ -447,14 +429,7 @@ class ExploreScreenState extends State<ExploreScreen>
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
), ),
), ),
Builder( TvToolbarOverlay(child: _buildTvToolbar(sources)),
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!),
),
),
], ],
), ),
); );
+39 -56
View File
@@ -31,6 +31,8 @@ import 'tabs/guide_tab.dart';
import 'tabs/recordings_tab.dart'; import 'tabs/recordings_tab.dart';
import 'tabs/whats_on_tab.dart'; import 'tabs/whats_on_tab.dart';
typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode});
enum LiveTvTab { guide, whatsOn, recordings } enum LiveTvTab { guide, whatsOn, recordings }
class LiveTvScreen extends StatefulWidget { class LiveTvScreen extends StatefulWidget {
@@ -66,13 +68,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Set<String> _favoriteKeys = {}; Set<String> _favoriteKeys = {};
List<FavoriteChannel> _favoriteChannels = []; List<FavoriteChannel> _favoriteChannels = [];
/// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. /// Favorite source URI, store key and persistence mode per Live TV server/DVR.
final Map<String, String> _favoriteSourceByLiveServer = {}; /// The source is built from machineIdentifier + EPG provider identifier.
final Map<String, String> _favoriteSourceByChannel = {}; final Map<String, _FavoriteScope> _favoriteScopeByLiveServer = {};
final Map<String, String> _favoriteStoreByLiveServer = {}; final Map<String, String> _liveServerKeyByChannel = {};
final Map<String, String> _favoriteStoreByChannel = {};
/// 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<String, String> _favoriteStoreBySource = {}; final Map<String, String> _favoriteStoreBySource = {};
final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {};
Future<void>? _channelsLoadFuture; Future<void>? _channelsLoadFuture;
int _favoritesLoadGeneration = 0; int _favoritesLoadGeneration = 0;
Future<void>? _favoritesLoadFuture; Future<void>? _favoritesLoadFuture;
@@ -90,8 +94,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; 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) { 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); String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key);
@@ -303,12 +312,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final allChannels = <LiveTvChannel>[]; final allChannels = <LiveTvChannel>[];
final seenChannels = <String>{}; final seenChannels = <String>{};
final favoriteSourceByLiveServer = <String, String>{}; final favoriteScopeByLiveServer = <String, _FavoriteScope>{};
final favoriteSourceByChannel = <String, String>{}; final liveServerKeyByChannel = <String, String>{};
final favoriteStoreByLiveServer = <String, String>{};
final favoriteStoreByChannel = <String, String>{};
final favoriteStoreBySource = <String, String>{}; final favoriteStoreBySource = <String, String>{};
final favoriteModeByStore = <String, FavoriteChannelPersistenceMode>{};
appLogger.d( appLogger.d(
'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}',
@@ -333,10 +339,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final sourceTitle = _sourceTitleForServerInfo(serverInfo); final sourceTitle = _sourceTitleForServerInfo(serverInfo);
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
favoriteSourceByLiveServer[liveServerKey] = source; favoriteScopeByLiveServer[liveServerKey] = (
favoriteStoreByLiveServer[liveServerKey] = storeKey; source: source,
storeKey: storeKey,
mode: liveTv.favoritePersistenceMode,
);
favoriteStoreBySource[source] = storeKey; favoriteStoreBySource[source] = storeKey;
favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode;
final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup);
// Plex's DVR exposes a separate enabled-channel mapping; Jellyfin // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin
@@ -355,9 +363,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
); );
final dedupKey = liveTvChannelScopeKey(scopedChannel); final dedupKey = liveTvChannelScopeKey(scopedChannel);
if (seenChannels.add(dedupKey)) { if (seenChannels.add(dedupKey)) {
final scopeKey = liveTvChannelScopeKey(scopedChannel); liveServerKeyByChannel[dedupKey] = liveServerKey;
favoriteSourceByChannel[scopeKey] = source;
favoriteStoreByChannel[scopeKey] = storeKey;
allChannels.add(scopedChannel); allChannels.add(scopedChannel);
} }
} }
@@ -378,24 +384,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
setState(() { setState(() {
_channels = allChannels; _channels = allChannels;
_favoriteSourceByLiveServer _favoriteScopeByLiveServer
..clear() ..clear()
..addAll(favoriteSourceByLiveServer); ..addAll(favoriteScopeByLiveServer);
_favoriteSourceByChannel _liveServerKeyByChannel
..clear() ..clear()
..addAll(favoriteSourceByChannel); ..addAll(liveServerKeyByChannel);
_favoriteStoreByLiveServer
..clear()
..addAll(favoriteStoreByLiveServer);
_favoriteStoreByChannel
..clear()
..addAll(favoriteStoreByChannel);
_favoriteStoreBySource _favoriteStoreBySource
..clear() ..clear()
..addAll(favoriteStoreBySource); ..addAll(favoriteStoreBySource);
_favoriteModeByStore
..clear()
..addAll(favoriteModeByStore);
_isLoading = false; _isLoading = false;
}); });
@@ -433,10 +430,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_favoritesLoaded = false; _favoritesLoaded = false;
_favoritesWritable = false; _favoritesWritable = false;
final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource); final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource);
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer); final scopeByLiveServer = Map<String, _FavoriteScope>.of(_favoriteScopeByLiveServer);
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
final storeBySource = Map<String, String>.of(_favoriteStoreBySource); final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
final merged = <FavoriteChannel>[]; final merged = <FavoriteChannel>[];
final successfulStores = <String>{}; final successfulStores = <String>{};
final failedStores = <String>{}; final failedStores = <String>{};
@@ -448,12 +443,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final liveTv = client.liveTv; final liveTv = client.liveTv;
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
storeByLiveServer[liveServerKey] = storeKey;
modeByStore[storeKey] = liveTv.favoritePersistenceMode;
try { try {
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
sourceByLiveServer[liveServerKey] = source; scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode);
storeBySource[source] = storeKey; storeBySource[source] = storeKey;
if (successfulStores.contains(storeKey)) continue; if (successfulStores.contains(storeKey)) continue;
@@ -482,18 +475,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
if (!mounted || loadGeneration != _favoritesLoadGeneration) return; if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
setState(() { setState(() {
_favoriteSourceByLiveServer _favoriteScopeByLiveServer
..clear() ..clear()
..addAll(sourceByLiveServer); ..addAll(scopeByLiveServer);
_favoriteStoreByLiveServer
..clear()
..addAll(storeByLiveServer);
_favoriteStoreBySource _favoriteStoreBySource
..clear() ..clear()
..addAll(storeBySource); ..addAll(storeBySource);
_favoriteModeByStore
..clear()
..addAll(modeByStore);
_favoriteChannels = merged; _favoriteChannels = merged;
_refreshFavoriteKeys(); _refreshFavoriteKeys();
_favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty;
@@ -515,8 +502,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_enqueueFavoriteMutation(() { _enqueueFavoriteMutation(() {
final source = _sourceForChannel(channel); final source = _sourceForChannel(channel);
final favoriteKey = favoriteChannelKey(source, channel.key); final favoriteKey = favoriteChannelKey(source, channel.key);
final scopeKey = liveTvChannelScopeKey(channel); final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey;
final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey];
if (storeKey != null) _favoriteStoreBySource[source] = storeKey; if (storeKey != null) _favoriteStoreBySource[source] = storeKey;
setState(() { setState(() {
@@ -596,16 +582,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
for (final serverInfo in multiServer.liveTvServers) { for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue; if (client == null) continue;
final liveServerKey = _liveServerScopeKey(serverInfo); final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)];
final storeKey = _favoriteStoreByLiveServer[liveServerKey]; if (scope == null || !writtenStores.add(scope.storeKey)) continue;
if (storeKey == null || !writtenStores.add(storeKey)) continue; final storeChannels = byStore[scope.storeKey] ?? const <FavoriteChannel>[];
final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; final channels = switch (scope.mode) {
final source = _favoriteSourceByLiveServer[liveServerKey]; FavoriteChannelPersistenceMode.sharedFullList => storeChannels,
if (source == null) continue;
final channels = switch (mode) {
FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const <FavoriteChannel>[],
FavoriteChannelPersistenceMode.serverSlice => FavoriteChannelPersistenceMode.serverSlice =>
(byStore[storeKey] ?? const <FavoriteChannel>[]).where((favorite) => favorite.source == source).toList(), storeChannels.where((favorite) => favorite.source == scope.source).toList(),
}; };
writes.add(client.liveTv.setFavoriteChannels(channels)); writes.add(client.liveTv.setFavoriteChannels(channels));
} }
@@ -568,16 +568,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
final playbackContext = await playbackResolver.resolve( final playbackContext = await playbackResolver.resolve(
metadata: metadata, PlaybackInitializationOptions(
selectedMediaIndex: targetMediaIndex, metadata: metadata,
selectedMediaSourceId: selectedMediaSourceId, selectedMediaIndex: targetMediaIndex,
preferredVersionSignature: preferredVersionSignature, selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
preferredSubtitleTrack: initializationSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: _offlineLibraryMode, offlineLibraryMode: _offlineLibraryMode,
qualityPreset: targetQualityPreset,
selectedAudioStreamId: targetAudioStreamId,
preferredSubtitleTrack: initializationSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
); );
if (!isCurrentReload()) return _MediaReloadOutcome.superseded; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
final result = playbackContext.result; final result = playbackContext.result;
@@ -110,15 +110,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
database: context.read<AppDatabase>(), database: context.read<AppDatabase>(),
); );
playbackContext = await playbackResolver.resolve( playbackContext = await playbackResolver.resolve(
metadata: _currentMetadata, PlaybackInitializationOptions(
selectedMediaIndex: _effectiveSelectedMediaIndex, metadata: _currentMetadata,
selectedMediaSourceId: _requestedMediaSourceId, selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: _requestedMediaSourceId,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: true, offlineLibraryMode: true,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
); );
if (playbackContext.result.videoUrl == null) { if (playbackContext.result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable); throw PlaybackException(t.messages.fileInfoNotAvailable);
+11 -9
View File
@@ -1060,16 +1060,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
database: context.read<AppDatabase>(), database: context.read<AppDatabase>(),
); );
_playbackDataFuture = playbackResolver.resolve( _playbackDataFuture = playbackResolver.resolve(
metadata: _currentMetadata, PlaybackInitializationOptions(
selectedMediaIndex: _effectiveSelectedMediaIndex, metadata: _currentMetadata,
selectedMediaSourceId: _requestedMediaSourceId, selectedMediaIndex: _effectiveSelectedMediaIndex,
preferredVersionSignature: widget.preferredVersionSignature, selectedMediaSourceId: _requestedMediaSourceId,
preferredVersionSignature: widget.preferredVersionSignature,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
),
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
preferredSubtitleTrack: _preferredSubtitleTrack,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
); );
// If MPV setup below throws before `_startPlayback` awaits this, // If MPV setup below throws before `_startPlayback` awaits this,
// tell Dart we've "handled" the future so it's not reported as an // tell Dart we've "handled" the future so it's not reported as an
+70 -97
View File
@@ -1646,6 +1646,20 @@ class DownloadManagerService {
return true; 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<bool> _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. /// Resolve metadata, video URL, and file path, then enqueue a background download task.
/// Returns true if successfully enqueued, false if it failed immediately. /// Returns true if successfully enqueued, false if it failed immediately.
Future<bool> _prepareAndEnqueueDownload( Future<bool> _prepareAndEnqueueDownload(
@@ -1753,38 +1767,30 @@ class DownloadManagerService {
if (_queueBlockedByStorageFailure) return true; if (_queueBlockedByStorageFailure) return true;
final metadata = resolvedMetadata; final metadata = resolvedMetadata;
final safBaseUri = _storageService.safBaseUri; final safBaseUri = _storageService.safBaseUri;
final DownloadTask task;
final String filePath;
final String? safRootUri;
if (_storageService.isUsingSaf && safBaseUri != null) { if (_storageService.isUsingSaf && safBaseUri != null) {
final safRootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri);
if (safRootUri == null) { if (rootUri == null) {
throw StateError('Selected SAF root has no persisted permission'); 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, // SAF mode: use UriDownloadTask (writes directly to content:// URI,
// with no pause/resume support). // with no pause/resume support).
final List<String> pathComponents; final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId);
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 safDirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components);
if (safDirUri == null) { if (safDirUri == null) {
throw Exception('Failed to create SAF directory'); throw Exception('Failed to create SAF directory');
} }
await _cleanupSafTargetFile(safDirUri, safFileName); await _cleanupSafTargetFile(safDirUri, target.fileName);
final task = UriDownloadTask( task = UriDownloadTask(
url: resolution.videoUrl!, url: resolution.videoUrl!,
filename: safFileName, filename: target.fileName,
directoryUri: Uri.parse(safDirUri), directoryUri: Uri.parse(safDirUri),
group: _downloadGroup, group: _downloadGroup,
updates: Updates.statusAndProgress, updates: Updates.statusAndProgress,
@@ -1794,82 +1800,60 @@ class DownloadManagerService {
metaData: globalKey, metaData: globalKey,
displayName: displayName, displayName: displayName,
); );
filePath = safDirUri;
_pendingDownloadContext[globalKey] = _DownloadContext( safRootUri = rootUri;
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);
} else { } 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( _pendingDownloadContext[globalKey] = _DownloadContext(
metadata: metadata, metadata: metadata,
queueItem: queueItem, queueItem: queueItem,
filePath: downloadFilePath, filePath: filePath,
extension: ext, extension: ext,
client: client, client: client,
showYear: showYear, showYear: showYear,
isSafMode: safRootUri != null,
safRootUri: safRootUri,
subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null,
); );
await _database.updateBgTaskId(globalKey, task.taskId); return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download');
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;
}); });
if (becameInactive) return true; if (becameInactive) return true;
return true; return true;
@@ -2417,23 +2401,12 @@ class DownloadManagerService {
} }
Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async {
final List<String> pathComponents; final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId);
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 dirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components);
if (dirUri == null) return null; if (dirUri == null) return null;
final child = await _safStorage.getChild(dirUri, [safFileName]); final child = await _safStorage.getChild(dirUri, [target.fileName]);
return child?.uri; return child?.uri;
} }
@@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -477,6 +478,28 @@ class DownloadStorageService {
/// Get the extension-less episode filename used for SAF lookups. /// Get the extension-less episode filename used for SAF lookups.
String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); 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<String> 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) { bool isSafUri(String storedPath) {
return storedPath.startsWith('content://'); return storedPath.startsWith('content://');
} }
+12 -10
View File
@@ -64,17 +64,19 @@ class ServerMusicSourceResolver implements MusicSourceResolver {
Future<MusicSource> resolve(MediaItem track) async { Future<MusicSource> resolve(MediaItem track) async {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve( final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve(
metadata: track, PlaybackInitializationOptions(
selectedMediaIndex: 0, 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, 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; final result = context.result;
@@ -8,8 +8,6 @@ import '../media/media_item.dart';
import '../media/media_item_types.dart'; import '../media/media_item_types.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../media/media_source_info.dart'; import '../media/media_source_info.dart';
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart';
import '../mpv/models.dart'; import '../mpv/models.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
@@ -110,19 +108,11 @@ class PlaybackInitializationService {
/// ///
/// Downloaded/offline path: when [preferOffline] finds a downloaded copy, /// Downloaded/offline path: when [preferOffline] finds a downloaded copy,
/// builds from cached [MediaSourceInfo] and local sidecars immediately. /// builds from cached [MediaSourceInfo] and local sidecars immediately.
Future<PlaybackInitializationResult> getPlaybackData({ Future<PlaybackInitializationResult> getPlaybackData(
required MediaItem metadata, PlaybackInitializationOptions options, {
required int selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
bool preferOffline = false, bool preferOffline = false,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
SubtitleTrack? preferredSubtitleTrack,
String? sessionIdentifier,
String? transcodeSessionId,
}) async { }) async {
final metadata = options.metadata;
final serverId = metadata.serverId ?? client?.serverId; final serverId = metadata.serverId ?? client?.serverId;
DownloadedVideoSource? offlineSource; DownloadedVideoSource? offlineSource;
@@ -130,8 +120,8 @@ class PlaybackInitializationService {
offlineSource = await _resolveOfflineVideoSource( offlineSource = await _resolveOfflineVideoSource(
ServerId(serverId), ServerId(serverId),
metadata.id, metadata.id,
mediaIndex: selectedMediaIndex, mediaIndex: options.selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId, selectedMediaSourceId: options.selectedMediaSourceId,
// With no client there is nothing to stream from, so any downloaded // With no client there is nothing to stream from, so any downloaded
// version beats failing. With a client the strict match must stand: // version beats failing. With a client the strict match must stand:
// an explicitly requested non-downloaded version streams from the // an explicitly requested non-downloaded version streams from the
@@ -156,20 +146,7 @@ class PlaybackInitializationService {
PlaybackInitializationResult result; PlaybackInitializationResult result;
try { try {
result = await client!.getPlaybackInitialization( result = await client!.getPlaybackInitialization(options);
PlaybackInitializationOptions(
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
preferredSubtitleTrack: preferredSubtitleTrack,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
),
);
} catch (e) { } catch (e) {
rethrow; rethrow;
} }
+9 -33
View File
@@ -1,11 +1,7 @@
import '../database/app_database.dart'; import '../database/app_database.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_server_client.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 'multi_server_manager.dart';
import 'playback_context.dart'; import 'playback_context.dart';
import 'playback_initialization_service.dart'; import 'playback_initialization_service.dart';
@@ -17,40 +13,20 @@ class PlaybackSourceResolver {
const PlaybackSourceResolver({required this.serverManager, required this.database}); const PlaybackSourceResolver({required this.serverManager, required this.database});
/// [preferOffline] overrides the default downloaded-copy preference /// [preferOffline] overrides the default downloaded-copy preference
/// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for /// (`offlineLibraryMode || options.qualityPreset.isOriginal`, so an omitted
/// flows that must stay on the server stream, e.g. a transcode restart. /// preset keeps it on). 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 Future<PlaybackContext> resolve(
/// backends only for [MediaKind.track] items ([qualityPreset] is PlaybackInitializationOptions options, {
/// video-shaped and ignored for tracks).
Future<PlaybackContext> resolve({
required MediaItem metadata,
required int selectedMediaIndex,
String? selectedMediaSourceId,
String? preferredVersionSignature,
required bool offlineLibraryMode, required bool offlineLibraryMode,
required TranscodeQualityPreset qualityPreset,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
SubtitleTrack? preferredSubtitleTrack,
String? sessionIdentifier,
String? transcodeSessionId,
bool? preferOffline, bool? preferOffline,
}) async { }) async {
final metadata = options.metadata;
final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode); final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode);
final service = PlaybackInitializationService(client: reportingClient, database: database); final service = PlaybackInitializationService(client: reportingClient, database: database);
final result = await service.getPlaybackData( final result = await service.getPlaybackData(
metadata: metadata, options,
selectedMediaIndex: selectedMediaIndex, preferOffline: preferOffline ?? (offlineLibraryMode || options.qualityPreset.isOriginal),
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal),
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
preferredSubtitleTrack: preferredSubtitleTrack,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
); );
final sourceKind = result.usesLocalMedia final sourceKind = result.usesLocalMedia
@@ -75,7 +51,7 @@ class PlaybackSourceResolver {
streamHeaders: _streamHeaders( streamHeaders: _streamHeaders(
client: reportingClient, client: reportingClient,
sourceKind: sourceKind, sourceKind: sourceKind,
sessionIdentifier: sessionIdentifier, sessionIdentifier: options.sessionIdentifier,
), ),
); );
} }
+14 -42
View File
@@ -183,31 +183,19 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async { Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> parseChannels(MediaServerResponse response) { List<LiveTvChannel> parseChannels(MediaServerResponse response) {
final container = _getMediaContainer(response); final container = _getMediaContainer(response);
if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { if (container == null || (container['Channel'] == null && container['Metadata'] == null)) {
appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}');
return [];
} }
if (container != null && container['Channel'] != null) { final rawChannels = container['Channel'];
return (container['Channel'] as List) if (rawChannels is List && rawChannels.isNotEmpty) {
.map( appLogger.d('EPG channel sample: ${rawChannels.first}');
(json) => LiveTvChannel.fromJson(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.where((ch) => ch.key.isNotEmpty)
.toList();
} }
if (container != null && container['Metadata'] != null) { return _extractContainerList(
return (container['Metadata'] as List) response,
.map( const ['Channel', 'Metadata'],
(json) => LiveTvChannel.fromJson( (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName),
json as Map<String, dynamic>, ).where((ch) => ch.key.isNotEmpty).toList();
).copyWith(serverId: serverId, serverName: serverName),
)
.where((ch) => ch.key.isNotEmpty)
.toList();
}
appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}');
return [];
} }
final allChannels = <LiveTvChannel>[]; final allChannels = <LiveTvChannel>[];
@@ -545,11 +533,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
if (container == null) return null; if (container == null) return null;
final containerStatus = container['status']; final containerStatus = container['status'];
final statusInt = containerStatus is num final statusInt = flexibleInt(containerStatus);
? containerStatus.toInt()
: containerStatus is String
? int.tryParse(containerStatus)
: null;
if (statusInt != null && statusInt != 0 && statusInt != 200) { if (statusInt != null && statusInt != 0 && statusInt != 200) {
final msg = container['message'] ?? t.liveTv.unknownError; final msg = container['message'] ?? t.liveTv.unknownError;
appLogger.w('Tune channel error: $msg (status: $containerStatus)'); appLogger.w('Tune channel error: $msg (status: $containerStatus)');
@@ -579,14 +563,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
if (op is Map) { if (op is Map) {
if (op['Metadata'] case [final Map firstMetadata, ...]) { if (op['Metadata'] case [final Map firstMetadata, ...]) {
if (firstMetadata['Media'] case [final Map firstMedia, ...]) { if (firstMetadata['Media'] case [final Map firstMedia, ...]) {
final rawBeginsAt = firstMedia['beginsAt']; beginsAt = flexibleInt(firstMedia['beginsAt']);
beginsAt = switch (rawBeginsAt) {
final num n => n.toInt(),
final String s => int.tryParse(s),
_ => null,
};
appLogger.d('beginsAt=$beginsAt'); appLogger.d('beginsAt=$beginsAt');
} }
} }
@@ -652,12 +629,7 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
if (media is List && media.isNotEmpty) { if (media is List && media.isNotEmpty) {
final firstMedia = media.first; final firstMedia = media.first;
if (firstMedia is Map<String, dynamic>) { if (firstMedia is Map<String, dynamic>) {
final rawBeginsAt = firstMedia['beginsAt']; beginsAt = flexibleInt(firstMedia['beginsAt']);
beginsAt = switch (rawBeginsAt) {
final num n => n.toInt(),
final String s => int.tryParse(s),
_ => null,
};
} }
} }
} }
+9 -35
View File
@@ -29,6 +29,9 @@ class DesktopWindowPadding {
/// Right padding for mobile devices to prevent actions from being too close to edge /// Right padding for mobile devices to prevent actions from being too close to edge
static const double mobileRight = 6.0; 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 /// 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 (context != null && SideNavigationScope.isPresent(context)) {
if (includeGestureDetector) { return includeGestureDetector ? wrapWithGestureDetector(leading, opaque: true) : leading;
return GestureDetector(
behavior: HitTestBehavior.opaque,
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
onPanDown: (_) {},
child: leading,
);
}
return leading;
} }
return ListenableBuilder( return ListenableBuilder(
listenable: FullscreenStateManager(), listenable: FullscreenStateManager(),
builder: (context, _) { builder: (context, _) {
final isFullscreen = FullscreenStateManager().isFullscreen;
final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft;
final paddedWidget = Padding( final paddedWidget = Padding(
padding: .only(left: leftPadding), padding: .only(left: DesktopWindowPadding.macOSLeftCurrent),
child: leading, child: leading,
); );
if (includeGestureDetector) { return includeGestureDetector ? wrapWithGestureDetector(paddedWidget, opaque: true) : paddedWidget;
return GestureDetector(
behavior: HitTestBehavior.opaque,
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
onPanDown: (_) {},
child: paddedWidget,
);
}
return paddedWidget;
}, },
); );
} }
@@ -102,12 +85,7 @@ class DesktopAppBarHelper {
return flexibleSpace; return flexibleSpace;
} }
return GestureDetector( return wrapWithGestureDetector(flexibleSpace);
behavior: HitTestBehavior.translucent,
// ignore: no-empty-block - consumes gesture to prevent macOS window dragging
onPanDown: (_) {},
child: flexibleSpace,
);
} }
/// Calculates the leading width for SliverAppBar to account for macOS traffic lights /// Calculates the leading width for SliverAppBar to account for macOS traffic lights
@@ -121,9 +99,7 @@ class DesktopAppBarHelper {
return null; return null;
} }
final isFullscreen = FullscreenStateManager().isFullscreen; return DesktopWindowPadding.macOSLeftCurrent + kToolbarHeight;
final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft;
return leftPadding + kToolbarHeight;
} }
/// Wraps a widget with GestureDetector on macOS to prevent window dragging /// Wraps a widget with GestureDetector on macOS to prevent window dragging
@@ -175,10 +151,8 @@ class DesktopTitleBarPadding extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: FullscreenStateManager(), listenable: FullscreenStateManager(),
builder: (context, _) { builder: (context, _) {
final isFullscreen = FullscreenStateManager().isFullscreen;
// In fullscreen, use minimal padding since traffic lights auto-hide // In fullscreen, use minimal padding since traffic lights auto-hide
final left = final left = leftPadding ?? DesktopWindowPadding.macOSLeftCurrent;
leftPadding ?? (isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft);
final right = rightPadding ?? 0.0; final right = rightPadding ?? 0.0;
if (left == 0.0 && right == 0.0) { if (left == 0.0 && right == 0.0) {
+8 -6
View File
@@ -14,7 +14,7 @@ import '../services/settings_service.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import 'catalog_navigation_helper.dart'; import 'catalog_navigation_helper.dart';
import 'music_navigation.dart'; import 'music_navigation.dart';
import 'plex_library_section_helpers.dart'; import 'plex_library_section_utils.dart';
import 'video_player_navigation.dart'; import 'video_player_navigation.dart';
/// Result of media navigation indicating what action was taken /// Result of media navigation indicating what action was taken
@@ -192,11 +192,13 @@ Future<MediaNavigationResult> navigateToMediaItem(
); );
// Handle library section items (shared whole-library entries) — Plex-only; // Handle library section items (shared whole-library entries) — Plex-only;
// [PlexLibrarySection.isLibrarySection] reads the stashed `key` from `raw`. // `PlexMappers` stashes the section path in `raw['key']`. Jellyfin "views"
if (mi.isLibrarySection) { // never appear inside a [MediaItem], so the gate never fires for them.
final sectionKey = mi.librarySectionKey; final rawKey = mi.raw?['key'];
if (sectionKey != null && mi.serverId != null) { if (rawKey is String && rawKey.startsWith('/library/sections/')) {
final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey); 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); MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey);
return MediaNavigationResult.librarySelected; return MediaNavigationResult.librarySelected;
} }
+145 -142
View File
@@ -162,48 +162,19 @@ class MediaServerHttpClient {
}) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); }) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
/// Fetch raw bytes (e.g. images, BIF files, subtitles). /// Fetch raw bytes (e.g. images, BIF files, subtitles).
Future<Uint8List> getBytes( Future<Uint8List> getBytes(String url, {Map<String, String>? headers, Duration? timeout, AbortController? abort}) {
String url, { return _perform<Uint8List>(
Map<String, String>? headers, 'GET',
Duration? timeout, url,
AbortController? abort, headers: headers,
}) async { timeout: timeout,
if (_closing) { abort: abort,
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); consume: (streamed, scope) async {
} final bytes = await scope.receive(streamed.stream.toBytes());
scope.logResponse(streamed.statusCode);
final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); return bytes;
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);
}
} }
/// Stream-download a URL directly into a file. /// Stream-download a URL directly into a file.
@@ -213,64 +184,48 @@ class MediaServerHttpClient {
Map<String, String>? headers, Map<String, String>? headers,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) async { }) {
if (_closing) { final tempFile = File('$filePath.download');
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); return _perform<void>(
} '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<void>();
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 file = File(filePath);
final requestAbort = AbortController(); await file.parent.create(recursive: true);
_activeAborts.add(requestAbort); if (await tempFile.exists()) await tempFile.delete();
final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); final sink = tempFile.openWrite();
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<void>();
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()) {
try { try {
await tempFile.delete(); await scope.receive(streamed.stream.pipe(sink));
} catch (_) {} } finally {
} await sink.close();
throw MediaServerHttpException.from(e, uri: uri); }
} finally { if (await file.exists()) await file.delete();
_activeAborts.remove(requestAbort); await tempFile.rename(filePath);
} },
);
} }
void close() { void close() {
@@ -297,69 +252,90 @@ class MediaServerHttpClient {
Object? body, Object? body,
Duration? timeout, Duration? timeout,
AbortController? abort, AbortController? abort,
}) {
return _perform<MediaServerResponse>(
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<T> _perform<T>(
String method,
String url, {
String? label,
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
Future<void> Function()? onError,
required Future<T> Function(http.StreamedResponse streamed, _RequestScope scope) consume,
}) async { }) async {
if (_closing) { if (_closing) {
throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing');
} }
final uri = _isAbsoluteUrl(path) final uri = _resolveUri(url, queryParameters);
? _appendQuery(Uri.parse(path), queryParameters) final operation = label ?? method;
: _buildUri(path, queryParameters);
final mergedHeaders = <String, String>{...defaultHeaders, ...?headers};
final requestAbort = AbortController(); final requestAbort = AbortController();
_activeAborts.add(requestAbort); _activeAborts.add(requestAbort);
final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort)); final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort));
request.headers.addAll(mergedHeaders); request.headers.addAll({...defaultHeaders, ...?headers});
_setBody(request, body); _setBody(request, body);
final sw = Stopwatch()..start(); final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout);
try { try {
final streamed = await _withAbortOnTimeout( final streamed = await _withAbortOnTimeout(
_client.send(request), _client.send(request),
timeout ?? connectTimeout, timeout ?? connectTimeout,
operation: '$method ${uri.path} connect', operation: '$operation ${uri.path} connect',
abort: requestAbort, abort: requestAbort,
); );
final effectiveUri = switch (streamed) { return await consume(streamed, scope);
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,
);
} catch (e) { } catch (e) {
requestAbort.abort(); requestAbort.abort();
sw.stop(); await onError?.call();
throw MediaServerHttpException.from(e, uri: uri); throw MediaServerHttpException.from(e, uri: uri);
} finally { } finally {
_activeAborts.remove(requestAbort); _activeAborts.remove(requestAbort);
@@ -410,6 +386,11 @@ class MediaServerHttpClient {
return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters); 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<String, dynamic>? queryParameters) =>
_isAbsoluteUrl(url) ? _appendQuery(Uri.parse(url), queryParameters) : _buildUri(url, queryParameters);
/// Append query parameters to an already-parsed URI. /// Append query parameters to an already-parsed URI.
Uri _appendQuery(Uri uri, Map<String, dynamic>? queryParameters) { Uri _appendQuery(Uri uri, Map<String, dynamic>? queryParameters) {
if (queryParameters == null || queryParameters.isEmpty) return uri; 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<T> receive<T>(Future<T> 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, /// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks,
/// log uploads, image fetches, etc). No base URL or default Plex headers. /// log uploads, image fetches, etc). No base URL or default Plex headers.
final httpClient = MediaServerHttpClient(); final httpClient = MediaServerHttpClient();
@@ -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);
}
}
+39
View File
@@ -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,
),
);
}
}
+24
View File
@@ -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!),
);
}
}
@@ -7,7 +7,7 @@ import '../../../utils/track_label_builder.dart';
import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/focusable_list_tile.dart';
class TrackSelectionHelper { class TrackSelectionHelper {
static Widget buildOffTile<T>({ static Widget buildOffTile({
required BuildContext context, required BuildContext context,
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
@@ -30,7 +30,7 @@ class TrackSelectionHelper {
); );
} }
static Widget buildTrackTile<T>({ static Widget buildTrackTile({
required BuildContext context, required BuildContext context,
required TrackLabel label, required TrackLabel label,
required bool isSelected, required bool isSelected,
@@ -155,7 +155,7 @@ class _SourceAudioColumn extends StatelessWidget {
initialIndex: selectedIndex, initialIndex: selectedIndex,
itemBuilder: (context, index, scope) { itemBuilder: (context, index, scope) {
final track = tracks[index]; final track = tracks[index];
return TrackSelectionHelper.buildTrackTile<AudioTrack>( return TrackSelectionHelper.buildTrackTile(
context: context, context: context,
key: scope.keyFor(index), key: scope.keyFor(index),
label: track.label, label: track.label,
@@ -196,7 +196,7 @@ class _SourceSubtitleColumn extends StatelessWidget {
footer: _buildSubtitleSearchFooter(context, trackControlsState), footer: _buildSubtitleSearchFooter(context, trackControlsState),
itemBuilder: (context, index, scope) { itemBuilder: (context, index, scope) {
if (index == 0) { if (index == 0) {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>( return TrackSelectionHelper.buildOffTile(
context: context, context: context,
key: scope.keyFor(index), key: scope.keyFor(index),
isSelected: selectedChoice.isOff, isSelected: selectedChoice.isOff,
@@ -207,7 +207,7 @@ class _SourceSubtitleColumn extends StatelessWidget {
} }
final track = tracks[index - 1]; final track = tracks[index - 1];
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>( return TrackSelectionHelper.buildTrackTile(
context: context, context: context,
label: track.labelForIndex(index - 1), label: track.labelForIndex(index - 1),
isSelected: track.id == selectedId, isSelected: track.id == selectedId,
@@ -264,7 +264,7 @@ class _AudioColumn extends StatelessWidget {
channels: track.channelsCount, channels: track.channelsCount,
index: index, index: index,
); );
return TrackSelectionHelper.buildTrackTile<AudioTrack>( return TrackSelectionHelper.buildTrackTile(
context: context, context: context,
key: scope.keyFor(index), key: scope.keyFor(index),
label: label, label: label,
@@ -324,7 +324,7 @@ class _SubtitleColumn extends StatelessWidget {
footer: _buildSubtitleSearchFooter(context, trackControlsState), footer: _buildSubtitleSearchFooter(context, trackControlsState),
itemBuilder: (context, index, scope) { itemBuilder: (context, index, scope) {
if (index == 0) { if (index == 0) {
return TrackSelectionHelper.buildOffTile<SubtitleTrack>( return TrackSelectionHelper.buildOffTile(
context: context, context: context,
key: scope.keyFor(index), key: scope.keyFor(index),
isSelected: isOffSelected, isSelected: isOffSelected,
@@ -357,7 +357,7 @@ class _SubtitleColumn extends StatelessWidget {
if (trackIndex >= tracks.length) { if (trackIndex >= tracks.length) {
final sourceIndex = trackIndex - tracks.length; final sourceIndex = trackIndex - tracks.length;
final sourceTrack = unloadedSourceSidecars[sourceIndex]; final sourceTrack = unloadedSourceSidecars[sourceIndex];
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>( return TrackSelectionHelper.buildTrackTile(
context: context, context: context,
label: sourceTrack.labelForIndex(trackIndex), label: sourceTrack.labelForIndex(trackIndex),
isSelected: false, isSelected: false,
@@ -387,7 +387,7 @@ class _SubtitleColumn extends StatelessWidget {
} }
} }
return TrackSelectionHelper.buildTrackTile<SubtitleTrack>( return TrackSelectionHelper.buildTrackTile(
context: context, context: context,
label: label, label: label,
isSelected: isPrimary, isSelected: isPrimary,
@@ -65,13 +65,15 @@ void main() {
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
preferOffline: true, preferOffline: true,
); );
@@ -94,13 +96,15 @@ void main() {
final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'track-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'track-1',
kind: MediaKind.track, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.track,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
preferOffline: true, preferOffline: true,
); );
@@ -121,13 +125,15 @@ void main() {
final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
preferOffline: true, preferOffline: true,
); );
@@ -153,13 +159,15 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 1,
), ),
selectedMediaIndex: 1,
preferOffline: true, preferOffline: true,
); );
@@ -186,13 +194,15 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
); );
expect(result.isOffline, isTrue); expect(result.isOffline, isTrue);
@@ -213,14 +223,16 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
), ),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
); );
expect(result.isOffline, isTrue); expect(result.isOffline, isTrue);
@@ -243,14 +255,16 @@ void main() {
final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final client = _StreamingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
), ),
selectedMediaIndex: 0,
selectedMediaSourceId: 'source-a',
preferOffline: true, preferOffline: true,
); );
@@ -297,13 +311,15 @@ void main() {
); );
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'item-1', metadata: testMediaItem(
backend: MediaBackend.jellyfin, id: 'item-1',
kind: MediaKind.movie, backend: MediaBackend.jellyfin,
serverId: ServerId('jf-machine'), kind: MediaKind.movie,
serverId: ServerId('jf-machine'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
preferOffline: true, preferOffline: true,
); );
@@ -325,13 +341,15 @@ void main() {
await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello');
final result = await PlaybackInitializationService(database: db).getPlaybackData( final result = await PlaybackInitializationService(database: db).getPlaybackData(
metadata: testMediaItem( PlaybackInitializationOptions(
id: 'movie-1', metadata: testMediaItem(
backend: MediaBackend.plex, id: 'movie-1',
kind: MediaKind.movie, backend: MediaBackend.plex,
serverId: ServerId('srv-1'), kind: MediaKind.movie,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
), ),
selectedMediaIndex: 0,
preferOffline: true, preferOffline: true,
); );
@@ -57,10 +57,12 @@ void main() {
manager.debugRegisterClientForTesting(client, online: false); manager.debugRegisterClientForTesting(client, online: false);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), PlaybackInitializationOptions(
selectedMediaIndex: 0, metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.original,
),
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original,
); );
expect(context.result.videoUrl, 'https://example.com/video.mp4'); expect(context.result.videoUrl, 'https://example.com/video.mp4');
@@ -80,11 +82,13 @@ void main() {
manager.debugRegisterClientForTesting(client, online: true); manager.debugRegisterClientForTesting(client, online: true);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), PlaybackInitializationOptions(
selectedMediaIndex: 0, metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.original,
sessionIdentifier: 'playback-session-id',
),
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original,
sessionIdentifier: 'playback-session-id',
); );
expect(context.sourceKind, PlaybackSourceKind.remoteDirect); expect(context.sourceKind, PlaybackSourceKind.remoteDirect);
@@ -104,11 +108,13 @@ void main() {
manager.debugRegisterClientForTesting(client, online: true); manager.debugRegisterClientForTesting(client, online: true);
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), PlaybackInitializationOptions(
selectedMediaIndex: 0, metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'),
selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.original,
sessionIdentifier: 'playback-session-id',
),
offlineLibraryMode: false, offlineLibraryMode: false,
qualityPreset: TranscodeQualityPreset.original,
sessionIdentifier: 'playback-session-id',
); );
expect(context.sourceKind, PlaybackSourceKind.remoteDirect); expect(context.sourceKind, PlaybackSourceKind.remoteDirect);