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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,26 +741,9 @@ 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),
|
||||
return ToolbarScrim(
|
||||
child: Row(
|
||||
children: [
|
||||
if (!PlatformDetector.isTV())
|
||||
@@ -778,11 +761,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateDown: _focusContentFromAppBar,
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
iconColor: foregroundColor,
|
||||
onPressed: _discover.load,
|
||||
),
|
||||
FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load),
|
||||
// Watch Together
|
||||
FocusableAction(
|
||||
onPressed: () =>
|
||||
@@ -795,10 +774,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
fill: watchTogether.isInSession ? 1 : 0,
|
||||
color: watchTogether.isInSession ? colorScheme.primary : foregroundColor,
|
||||
),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
|
||||
),
|
||||
onPressed: () =>
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
|
||||
tooltip: t.watchTogether.title,
|
||||
),
|
||||
if (watchTogether.isInSession && watchTogether.participantCount > 1)
|
||||
@@ -826,10 +803,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (isDesktop) {
|
||||
RemoteSessionDialog.show(context);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen()));
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
@@ -887,8 +861,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,25 +331,9 @@ 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),
|
||||
return ToolbarScrim(
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
@@ -393,13 +377,11 @@ class ExploreScreenState extends State<ExploreScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: metadata,
|
||||
selectedMediaIndex: targetMediaIndex,
|
||||
selectedMediaSourceId: selectedMediaSourceId,
|
||||
preferredVersionSignature: preferredVersionSignature,
|
||||
offlineLibraryMode: _offlineLibraryMode,
|
||||
qualityPreset: targetQualityPreset,
|
||||
selectedAudioStreamId: targetAudioStreamId,
|
||||
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
),
|
||||
offlineLibraryMode: _offlineLibraryMode,
|
||||
);
|
||||
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(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: _currentMetadata,
|
||||
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
||||
selectedMediaSourceId: _requestedMediaSourceId,
|
||||
offlineLibraryMode: true,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
),
|
||||
offlineLibraryMode: true,
|
||||
);
|
||||
if (playbackContext.result.videoUrl == null) {
|
||||
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
||||
|
||||
@@ -1060,16 +1060,18 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
database: context.read<AppDatabase>(),
|
||||
);
|
||||
_playbackDataFuture = playbackResolver.resolve(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: _currentMetadata,
|
||||
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
||||
selectedMediaSourceId: _requestedMediaSourceId,
|
||||
preferredVersionSignature: widget.preferredVersionSignature,
|
||||
offlineLibraryMode: false,
|
||||
qualityPreset: _selectedQualityPreset,
|
||||
selectedAudioStreamId: _selectedAudioStreamId,
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
// If MPV setup below throws before `_startPlayback` awaits this,
|
||||
// tell Dart we've "handled" the future so it's not reported as an
|
||||
|
||||
@@ -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,33 +1800,13 @@ 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;
|
||||
}
|
||||
|
||||
filePath = safDirUri;
|
||||
safRootUri = rootUri;
|
||||
} else {
|
||||
await _replaceDownloadSafRootClaim(globalKey, null);
|
||||
|
||||
// Normal mode: use DownloadTask with pause/resume support.
|
||||
String downloadFilePath;
|
||||
final String downloadFilePath;
|
||||
if (metadata.isMovie) {
|
||||
downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext);
|
||||
} else if (metadata.isEpisode) {
|
||||
@@ -1838,7 +1824,7 @@ class DownloadManagerService {
|
||||
|
||||
await File(downloadFilePath).parent.create(recursive: true);
|
||||
|
||||
final task = DownloadTask(
|
||||
task = DownloadTask(
|
||||
url: resolution.videoUrl!,
|
||||
filename: path.basename(downloadFilePath),
|
||||
directory: path.dirname(downloadFilePath),
|
||||
@@ -1851,25 +1837,23 @@ class DownloadManagerService {
|
||||
metaData: globalKey,
|
||||
displayName: displayName,
|
||||
);
|
||||
filePath = downloadFilePath;
|
||||
safRootUri = null;
|
||||
}
|
||||
|
||||
_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://');
|
||||
}
|
||||
|
||||
@@ -64,9 +64,9 @@ class ServerMusicSourceResolver implements MusicSourceResolver {
|
||||
Future<MusicSource> resolve(MediaItem track) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: track,
|
||||
selectedMediaIndex: 0,
|
||||
offlineLibraryMode: false,
|
||||
// Video-shaped preset is ignored for tracks; `original` also keeps the
|
||||
// resolver's downloaded-copy preference on.
|
||||
qualityPreset: TranscodeQualityPreset.original,
|
||||
@@ -75,6 +75,8 @@ class ServerMusicSourceResolver implements MusicSourceResolver {
|
||||
// concurrent gapless arming never reuses a live transcode session.
|
||||
sessionIdentifier: generateSessionIdentifier(),
|
||||
transcodeSessionId: generateSessionIdentifier(),
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,32 +183,20 @@ 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) {
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (container == null || (container['Channel'] == null && container['Metadata'] == null)) {
|
||||
appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}');
|
||||
return [];
|
||||
}
|
||||
final rawChannels = container['Channel'];
|
||||
if (rawChannels is List && rawChannels.isNotEmpty) {
|
||||
appLogger.d('EPG channel sample: ${rawChannels.first}');
|
||||
}
|
||||
return _extractContainerList(
|
||||
response,
|
||||
const ['Channel', 'Metadata'],
|
||||
(json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName),
|
||||
).where((ch) => ch.key.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
for (final provider in _epgProvidersForLineup(lineup)) {
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
} catch (e) {
|
||||
requestAbort.abort();
|
||||
sw.stop();
|
||||
throw MediaServerHttpException.from(e, uri: uri);
|
||||
} finally {
|
||||
_activeAborts.remove(requestAbort);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 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 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 {
|
||||
final streamed = await _withAbortOnTimeout(
|
||||
_client.send(request),
|
||||
timeout ?? connectTimeout,
|
||||
operation: 'download ${uri.path} connect',
|
||||
abort: requestAbort,
|
||||
);
|
||||
|
||||
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: uri,
|
||||
requestUri: scope.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,
|
||||
);
|
||||
await scope.receive(streamed.stream.pipe(sink));
|
||||
} 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 {
|
||||
await tempFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
throw MediaServerHttpException.from(e, uri: uri);
|
||||
} finally {
|
||||
_activeAborts.remove(requestAbort);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void close() {
|
||||
@@ -297,45 +252,23 @@ class MediaServerHttpClient {
|
||||
Object? body,
|
||||
Duration? timeout,
|
||||
AbortController? abort,
|
||||
}) 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 requestAbort = AbortController();
|
||||
_activeAborts.add(requestAbort);
|
||||
final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort));
|
||||
request.headers.addAll(mergedHeaders);
|
||||
_setBody(request, body);
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final streamed = await _withAbortOnTimeout(
|
||||
_client.send(request),
|
||||
timeout ?? connectTimeout,
|
||||
operation: '$method ${uri.path} connect',
|
||||
abort: requestAbort,
|
||||
);
|
||||
}) {
|
||||
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,
|
||||
_ => uri,
|
||||
_ => scope.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);
|
||||
final bytes = await scope.receive(streamed.stream.toBytes());
|
||||
scope.logResponse(streamed.statusCode);
|
||||
|
||||
dynamic data;
|
||||
try {
|
||||
@@ -346,7 +279,7 @@ class MediaServerHttpClient {
|
||||
type: MediaServerHttpErrorType.unknown,
|
||||
statusCode: streamed.statusCode,
|
||||
responseData: body,
|
||||
requestUri: uri,
|
||||
requestUri: scope.uri,
|
||||
message: 'Failed to decode response body: $e',
|
||||
);
|
||||
}
|
||||
@@ -354,12 +287,55 @@ class MediaServerHttpClient {
|
||||
statusCode: streamed.statusCode,
|
||||
data: data,
|
||||
headers: streamed.headers,
|
||||
requestUri: uri,
|
||||
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 = _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({...defaultHeaders, ...?headers});
|
||||
_setBody(request, body);
|
||||
|
||||
final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout);
|
||||
try {
|
||||
final streamed = await _withAbortOnTimeout(
|
||||
_client.send(request),
|
||||
timeout ?? connectTimeout,
|
||||
operation: '$operation ${uri.path} connect',
|
||||
abort: requestAbort,
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +65,7 @@ void main() {
|
||||
await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope());
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -72,6 +73,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -94,6 +96,7 @@ void main() {
|
||||
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
|
||||
|
||||
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'track-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -101,6 +104,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -121,6 +125,7 @@ void main() {
|
||||
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
|
||||
|
||||
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -128,6 +133,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -153,6 +159,7 @@ void main() {
|
||||
);
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -160,6 +167,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 1,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -186,6 +194,7 @@ void main() {
|
||||
);
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -193,6 +202,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.isOffline, isTrue);
|
||||
@@ -213,6 +223,7 @@ void main() {
|
||||
);
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -221,6 +232,7 @@ void main() {
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
selectedMediaSourceId: 'source-a',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.isOffline, isTrue);
|
||||
@@ -243,6 +255,7 @@ void main() {
|
||||
final client = _StreamingPlaybackClient(serverId: ServerId('srv-1'));
|
||||
|
||||
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -251,6 +264,7 @@ void main() {
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
selectedMediaSourceId: 'source-a',
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -297,6 +311,7 @@ void main() {
|
||||
);
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'item-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
@@ -304,6 +319,7 @@ void main() {
|
||||
serverId: ServerId('jf-machine'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
@@ -325,6 +341,7 @@ void main() {
|
||||
await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello');
|
||||
|
||||
final result = await PlaybackInitializationService(database: db).getPlaybackData(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(
|
||||
id: 'movie-1',
|
||||
backend: MediaBackend.plex,
|
||||
@@ -332,6 +349,7 @@ void main() {
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
),
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
|
||||
@@ -57,10 +57,12 @@ void main() {
|
||||
manager.debugRegisterClientForTesting(client, online: false);
|
||||
|
||||
final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
|
||||
selectedMediaIndex: 0,
|
||||
offlineLibraryMode: false,
|
||||
qualityPreset: TranscodeQualityPreset.original,
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
|
||||
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(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'),
|
||||
selectedMediaIndex: 0,
|
||||
offlineLibraryMode: false,
|
||||
qualityPreset: TranscodeQualityPreset.original,
|
||||
sessionIdentifier: 'playback-session-id',
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
|
||||
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(
|
||||
PlaybackInitializationOptions(
|
||||
metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'),
|
||||
selectedMediaIndex: 0,
|
||||
offlineLibraryMode: false,
|
||||
qualityPreset: TranscodeQualityPreset.original,
|
||||
sessionIdentifier: 'playback-session-id',
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
|
||||
expect(context.sourceKind, PlaybackSourceKind.remoteDirect);
|
||||
|
||||
Reference in New Issue
Block a user