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