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