refactor(app): share client and presentation scaffolds

This commit is contained in:
edde746
2026-07-12 17:31:12 +02:00
parent 97f7508067
commit 6506f36843
24 changed files with 2142 additions and 1943 deletions
+12 -97
View File
@@ -42,12 +42,11 @@ import '../services/settings_service.dart';
import '../widgets/settings_builder.dart';
import '../widgets/fitting_title_text.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
import '../widgets/tv_spotlight_scaffold.dart';
import '../mixins/refreshable.dart';
import '../mixins/tab_visibility_aware.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/debouncer.dart';
import '../utils/dialogs.dart';
import '../utils/formatters.dart';
import '../utils/media_navigation_helper.dart';
@@ -101,12 +100,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
bool _isAutoScrollPaused = false;
bool _heroFocusPausedAutoScroll = false;
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
// TvSpotlightBackground subtree, never the rail/rows.
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
// Settle delay so d-pad scrubbing across a row doesn't fetch/decode a
// full-screen backdrop for every intermediate item.
final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150));
final TvSpotlightController _spotlight = TvSpotlightController();
bool _isTabVisible = true;
// Track initial load so we can focus hero when content first appears
@@ -172,14 +166,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.settingsRead(SettingsService.showHeroSection);
MediaItem? get _defaultSpotlightItem {
if (_onDeck.isNotEmpty) return _onDeck.first;
for (final hub in _hubs) {
if (hub.items.isNotEmpty) return hub.items.first;
}
return null;
}
// Memoized on provider list identity (the provider always replaces _onDeck/
// _hubs with fresh instances on change, never mutates in place) so unrelated
// rebuilds hand TvBrowseRail the same hubs list and its didUpdateWidget
@@ -210,25 +196,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
return hubs;
}
MediaItem? get _effectiveSpotlightItem {
final current = _spotlightItem.value;
if (current == null) return _defaultSpotlightItem;
if (_onDeck.any((item) => item.globalKey == current.globalKey)) return current;
for (final hub in _hubs) {
if (hub.items.any((item) => item.globalKey == current.globalKey)) return current;
}
return _defaultSpotlightItem;
}
void _setSpotlightItem(MediaItem item) {
// Same-key check lives inside the callback: an A→B→A scrub must cancel
// the pending B, not early-return and let it fire.
_spotlightDebouncer.run(() {
if (!mounted) return;
if (_spotlightItem.value?.globalKey == item.globalKey) return;
_spotlightItem.value = item;
});
}
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
void _scrollToTop() {
if (!_scrollController.hasClients) return;
@@ -473,8 +441,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
WidgetsBinding.instance.removeObserver(this);
_autoScrollTimer?.cancel();
_indicatorTimer?.cancel();
_spotlightDebouncer.dispose();
_spotlightItem.dispose();
_spotlight.dispose();
_indicatorProgress.dispose();
_heroController.dispose();
_scrollController.dispose();
@@ -1204,75 +1171,23 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
Widget _buildTvContent(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final svc = SettingsService.instance;
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
final hubsSpanMultipleServers = _hubsSpanMultipleServers();
final browseHubs = _tvBrowseHubs;
final scale = TvLayoutConstants.scaleForSize(size);
// Only layout-aspect (flip-stable) scope values may be read here: an
// offset-aspect read at this level would rebuild the whole screen on
// every sidebar focus flip. Offset values are read in small Builders
// around the widgets that position against them.
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
final railHeight = browseHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: railSize,
hubs: browseHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: theme.scaffoldBackgroundColor,
child: Stack(
return TvSpotlightScaffold(
hubs: browseHubs,
spotlightListenable: _spotlight,
resolveSpotlight: () => _spotlight.resolve(browseHubs),
resolveClient: _getMediaClientForItem,
hideSpoilers: hideSpoilers,
foreground: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
// The animated -bleed mirrors the content-slide tween in MainScreen,
// keeping the full-bleed background viewport-pinned while the
// content box slides during sidebar expansion. The Builder scopes
// the offset-aspect dependency to just this subtree.
Builder(
builder: (context) {
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
return SideNavigationBleedBuilder(
targetBleed: foregroundLeft,
child: ValueListenableBuilder<MediaItem?>(
valueListenable: _spotlightItem,
builder: (context, _, _) {
final spotlight = _effectiveSpotlightItem;
return TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
hideSpoilers: hideSpoilers,
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + foregroundLeft,
compact: true,
showPrimaryAction: false,
);
},
),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
);
},
),
if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
if (_errorMessage != null)
Center(
+24 -92
View File
@@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/hub_section.dart';
import '../widgets/settings_builder.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
import '../widgets/tv_spotlight_scaffold.dart';
import 'catalog_search_screen.dart';
import 'libraries/state_messages.dart';
@@ -51,9 +51,8 @@ class ExploreScreenState extends State<ExploreScreen>
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
final _actionBarKey = GlobalKey<FocusableActionBarState>();
// TV spotlight layout (mirrors LibraryRecommendedTab's rail + backdrop).
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
final TvSpotlightController _spotlight = TvSpotlightController();
@override
void initState() {
@@ -82,7 +81,7 @@ class ExploreScreenState extends State<ExploreScreen>
@override
void dispose() {
_spotlightItem.dispose();
_spotlight.dispose();
super.dispose();
}
@@ -95,19 +94,7 @@ class ExploreScreenState extends State<ExploreScreen>
_orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory();
}
void _setSpotlightItem(MediaItem item) {
_spotlightItem.value = item;
}
MediaItem? _effectiveSpotlightItem(List<ExploreRowHub> rowHubs) {
final current = _spotlightItem.value;
if (current != null) {
for (final rowHub in rowHubs) {
if (rowHub.hub.items.any((item) => item.id == current.id)) return current;
}
}
return rowHubs.firstOrNull?.hub.items.firstOrNull;
}
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
void _updateHubKeys(List<ExploreRowHub> rowHubs) {
final liveIds = <String>{for (final rowHub in rowHubs) rowHub.hub.id};
@@ -303,82 +290,27 @@ class ExploreScreenState extends State<ExploreScreen>
Widget _buildTvContent(List<ExploreRowHub> rowHubs) {
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final svc = SettingsService.instance;
final scale = TvLayoutConstants.scaleForSize(size);
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
final railHeight = TvBrowseRailLayout.estimateHeight(
size: railSize,
return TvSpotlightScaffold(
hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: theme.scaffoldBackgroundColor,
child: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
Builder(
builder: (context) {
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
return SideNavigationBleedBuilder(
targetBleed: foregroundLeft,
child: ValueListenableBuilder<MediaItem?>(
valueListenable: _spotlightItem,
builder: (context, _, _) {
final spotlight = _effectiveSpotlightItem(rowHubs);
return TvSpotlightBackground(
item: spotlight,
client: context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId)),
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + foregroundLeft,
compact: true,
showPrimaryAction: false,
);
},
),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
);
},
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
key: _tvBrowseRailKey,
hubs: tvHubs,
iconForHub: (hub, _) => _rowIcon(_rowForHub(hub) ?? CatalogRowId.watchlist),
onFocusedItemChanged: _setSpotlightItem,
loadMoreItems: (hub) {
final row = _rowForHub(hub);
return row == null ? Future.value(hub.items) : _explore.loadAllForRow(row);
},
onNavigateToSidebar: _navigateToSidebar,
onBack: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
),
],
spotlightListenable: _spotlight,
resolveSpotlight: () => _spotlight.resolve(tvHubs),
resolveClient: (spotlight) => context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId)),
foreground: Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
key: _tvBrowseRailKey,
hubs: tvHubs,
iconForHub: (hub, _) => _rowIcon(_rowForHub(hub) ?? CatalogRowId.watchlist),
onFocusedItemChanged: _setSpotlightItem,
loadMoreItems: (hub) {
final row = _rowForHub(hub);
return row == null ? Future.value(hub.items) : _explore.loadAllForRow(row);
},
onNavigateToSidebar: _navigateToSidebar,
onBack: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
),
);
+34 -102
View File
@@ -9,11 +9,9 @@ import '../services/settings_service.dart';
import '../utils/platform_detector.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/settings_builder.dart';
import '../utils/grid_size_calculator.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
import '../widgets/media_card_sliver_layout.dart';
import '../widgets/skeleton_media_card.dart';
import '../widgets/sliver_cross_axis_layout_builder.dart';
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
/// Flutter widget Key.
@@ -176,75 +174,36 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final svc = SettingsService.instance;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
if (isListMode) {
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
key: Key(_idForItem(item)),
item: item,
focusNode: focusNode,
disableScale: true,
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
onNavigateUp: index == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
},
),
);
}
return SliverPadding(
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: items.length,
density: libraryDensity,
padding: const EdgeInsets.all(8),
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
density: libraryDensity,
fullBleedImage: fullCardLayout,
shape: shape,
);
return SliverGrid.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final inFirstRow = GridSizeCalculator.isFirstRow(index, geometry.columnCount);
final focusNode = _focusNodeForIndex(index);
fullBleedImage: fullCardLayout,
shape: shape,
itemBuilder: (context, position) {
final index = position.index;
final item = items[index];
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
key: Key(_idForItem(item)),
item: item,
focusNode: focusNode,
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout,
onNavigateUp: inFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
},
);
},
),
return FocusableMediaCard(
key: Key(_idForItem(item)),
item: item,
focusNode: focusNode,
disableScale: position.disableScale,
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: fullCardLayout && position.isGrid,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
},
);
},
);
@@ -266,7 +225,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final svc = SettingsService.instance;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
@@ -292,41 +251,14 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
);
}
if (isListMode) {
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalItems,
itemBuilder: (context, index) => buildTile(index, inFirstRow: index == 0, disableScale: true),
),
);
}
return SliverPadding(
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalItems,
density: libraryDensity,
padding: const EdgeInsets.all(8),
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
density: libraryDensity,
fullBleedImage: fullCardLayout,
);
return SliverGrid.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalItems,
itemBuilder: (context, index) => buildTile(
index,
inFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
disableScale: false,
),
);
},
),
fullBleedImage: fullCardLayout,
itemBuilder: (context, position) =>
buildTile(position.index, inFirstRow: position.isFirstRow, disableScale: position.disableScale),
);
},
);
+33 -84
View File
@@ -14,14 +14,12 @@ import '../services/settings_service.dart';
import '../widgets/settings_builder.dart';
import '../utils/app_logger.dart';
import '../utils/continuation_pagination_coordinator.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/platform_detector.dart';
import '../utils/plex_library_section_utils.dart';
import '../utils/provider_extensions.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_card_sliver_layout.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/media_grid_delegate.dart';
import '../widgets/sliver_cross_axis_layout_builder.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart';
@@ -479,7 +477,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
],
builder: (context) {
final svc = SettingsService.instance;
final isListMode = svc.read(SettingsService.viewMode) == ViewMode.list;
final viewMode = svc.read(SettingsService.viewMode);
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
@@ -503,88 +501,39 @@ class _HubDetailScreenState extends State<HubDetailScreen>
_filteredItems.isNotEmpty &&
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
if (isListMode) {
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
focusNode: focusNode,
item: item,
disableScale: true,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: index == 0 ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
);
},
),
);
}
return SliverPadding(
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: _filteredItems.length,
density: libraryDensity,
padding: const EdgeInsets.all(8),
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
density: libraryDensity,
usePaddingAware: true,
horizontalPadding: 16,
useWideAspectRatio: useWideLayout,
fullBleedImage: fullCardLayout,
shape: isSquareHub ? CardShape.square : null,
);
final columnCount = geometry.columnCount;
usePaddingAware: true,
horizontalPadding: 16,
useWideAspectRatio: useWideLayout,
fullBleedImage: fullCardLayout,
shape: isSquareHub ? CardShape.square : null,
itemBuilder: (context, position) {
final index = position.index;
final item = _filteredItems[index];
final focusNode = _focusNodeForIndex(index);
return SliverGrid(
gridDelegate: geometry.delegate,
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _filteredItems[index];
final focusNode = _focusNodeForIndex(index);
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
return FocusableMediaCard(
focusNode: focusNode,
item: item,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: isFirstRow ? navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout,
);
},
childCount: _filteredItems.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
);
},
),
return FocusableMediaCard(
focusNode: focusNode,
item: item,
disableScale: position.disableScale,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout && position.isGrid,
);
},
);
},
),
@@ -38,8 +38,7 @@ import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/listenable_selector.dart';
import '../../../widgets/loading_indicator_box.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/media_card_list_layout.dart';
import '../../../widgets/bottom_sheet_page_scaffold.dart';
import '../../../widgets/overlay_sheet.dart';
@@ -1873,128 +1872,91 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final browseShape = isMusicGrouping ? CardShape.square : null;
if (viewMode == ViewMode.list) {
// In list view, all items are in a single column (first column)
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio, shape: browseShape);
return SliverPadding(
padding: .fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: itemCount,
itemBuilder: (context, index) {
final item = loadedItems[index];
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
final child = _cardMemo.widgetFor(
index,
item,
epoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
build: () => _buildMediaCardItem(
index,
isFirstRow: index == 0,
isFirstColumn: true, // List view = single column
isLastColumn: true,
disableScale: true,
columnCount: 1,
itemCount: itemCount,
),
);
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
},
),
);
} else {
// In grid view, calculate columns and pass to item builder
// Use 16:9 aspect ratio when browsing episodes with episode thumbnail mode
final hasAlphaBarReservation = rightPadding > 8.0;
return SliverPadding(
padding: .fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
// Compute column count from the width the grid would have without
// the alpha bar's reservation, so toggling the bar doesn't repack
// the grid into one fewer column and blow up poster size.
crossAxisExtentForColumnCount: hasAlphaBarReservation ? crossAxisExtent + (rightPadding - 8.0) : null,
density: libraryDensity,
useWideAspectRatio: useWideRatio,
shape: browseShape,
fullBleedImage: fullCardLayout,
);
final columnCount = geometry.columnCount;
// Cache grid metrics for alpha jump bar scroll calculations
_scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: columnCount,
rowHeight: geometry.itemHeight + geometry.spacing,
itemWidth: geometry.itemWidth,
itemHeight: geometry.itemHeight,
);
// Everything the card closures capture; a change flushes the memo
// so stale nav closures can't misroute d-pad focus.
final cardEpoch = (
ViewMode.grid,
columnCount,
itemCount,
fullCardLayout,
useWideRatio,
browseShape,
libraryDensity,
_shouldShowAlphaJumpBar,
isPhone,
);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: itemCount,
itemBuilder: (context, index) {
final item = loadedItems[index];
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch);
if (cached != null) return cached;
// Fresh inflation. While the grid is actually scrolling in
// pointer/touch mode, respect the global per-frame budget:
// over-budget cards render as skeletons and upgrade a frame
// later, so a row entering the viewport can't drop a frame.
// Keyboard/d-pad mode is exempt — skeletons aren't focusable
// and would break traversal; idle fills stay instant.
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: cardEpoch,
build: () => _buildMediaCardItem(
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
isLastColumn: (index % columnCount) == (columnCount - 1),
columnCount: columnCount,
itemCount: itemCount,
fullBleedImage: fullCardLayout,
),
);
},
);
},
),
);
}
final hasAlphaBarReservation = rightPadding > 8.0;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: itemCount,
density: libraryDensity,
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
useWideAspectRatio: useWideRatio,
shape: browseShape,
fullBleedImage: fullCardLayout,
crossAxisExtentForColumnCount: hasAlphaBarReservation
? (crossAxisExtent) => crossAxisExtent + (rightPadding - 8.0)
: null,
onGridGeometry: (geometry) {
_scrollMetrics = LibraryAlphaScrollMetrics(
columnCount: geometry.columnCount,
rowHeight: geometry.itemHeight + geometry.spacing,
itemWidth: geometry.itemWidth,
itemHeight: geometry.itemHeight,
);
},
listEpoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
gridEpochBuilder: (geometry) => (
ViewMode.grid,
geometry.columnCount,
itemCount,
fullCardLayout,
useWideRatio,
browseShape,
libraryDensity,
_shouldShowAlphaJumpBar,
isPhone,
),
itemBuilder: (context, position) {
final index = position.index;
final item = loadedItems[index];
if (item == null) {
_scheduleRangeLoad();
return const SkeletonMediaCard();
}
if (!position.isGrid) {
final child = _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildMediaCardItem(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: true,
isLastColumn: true,
disableScale: true,
columnCount: 1,
itemCount: itemCount,
),
);
return index == 0 ? _buildMeasuredFirstListItem(child) : child;
}
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildMediaCardItem(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
isLastColumn: position.isLastColumn,
columnCount: position.columnCount,
itemCount: itemCount,
fullBleedImage: fullCardLayout,
),
);
},
);
}
Widget _buildMediaCardItem(
@@ -7,18 +7,16 @@ import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../services/settings_service.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
@@ -124,10 +122,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
clipBehavior: Clip.none,
slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
if (viewMode == ViewMode.list)
_buildListSliver(density)
else
_buildGridSliver(density, fullCardLayout: fullCardLayout),
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
],
);
},
@@ -141,83 +136,52 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
return base.copyWith(top: base.top + _focusDecorationPadding);
}
Widget _buildListSliver(int density) {
return SliverPadding(
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalSize,
itemBuilder: (context, index) {
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
fullBleedImage: fullCardLayout,
listEpoch: (ViewMode.list, totalSize, density),
gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density),
itemBuilder: (context, position) {
final index = position.index;
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
if (!position.isGrid) {
return _cardMemo.widgetFor(
index,
item,
epoch: (ViewMode.list, totalSize, density),
build: () => _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
epoch: position.layoutEpoch!,
build: () =>
_buildMediaCardItem(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
);
},
),
);
}
}
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
return SliverPadding(
padding: _effectivePadding,
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
density: density,
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: position.layoutEpoch!,
build: () => _buildMediaCardItem(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: fullCardLayout,
);
// Everything the card closures capture; a change flushes the memo.
final cardEpoch = (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalSize,
itemBuilder: (context, index) {
final item = loadedItems[index];
if (item == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch);
if (cached != null) return cached;
// Budget fresh inflations while scrolling in pointer/touch mode
// (see CardInflationBudget); skeletons upgrade a frame later.
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
item,
epoch: cardEpoch,
build: () => _buildMediaCardItem(
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
fullBleedImage: fullCardLayout,
),
);
},
);
},
),
),
);
},
);
}
@@ -8,18 +8,16 @@ import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../services/settings_service.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.dart';
import '../../../utils/platform_detector.dart';
import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/media_card_sliver_layout.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart';
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
import '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
@@ -129,10 +127,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
clipBehavior: Clip.none,
slivers: [
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
if (viewMode == ViewMode.list)
_buildListSliver(density)
else
_buildGridSliver(density, fullCardLayout: fullCardLayout),
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
],
);
},
@@ -146,83 +141,52 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
return base.copyWith(top: base.top + _focusDecorationPadding);
}
Widget _buildListSliver(int density) {
return SliverPadding(
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalSize,
density: density,
padding: _effectivePadding,
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalSize,
itemBuilder: (context, index) {
final playlist = loadedItems[index];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
fullBleedImage: fullCardLayout,
listEpoch: (ViewMode.list, totalSize, density),
gridEpochBuilder: (geometry) => (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density),
itemBuilder: (context, position) {
final index = position.index;
final playlist = loadedItems[index];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
if (!position.isGrid) {
return _cardMemo.widgetFor(
index,
playlist,
epoch: (ViewMode.list, totalSize, density),
build: () => _buildPlaylistCard(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
epoch: position.layoutEpoch!,
build: () =>
_buildPlaylistCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
);
},
),
);
}
}
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
return SliverPadding(
padding: _effectivePadding,
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
density: density,
final cached = _cardMemo.tryGet(index, playlist, epoch: position.layoutEpoch!);
if (cached != null) return cached;
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
playlist,
epoch: position.layoutEpoch!,
build: () => _buildPlaylistCard(
index,
isFirstRow: position.isFirstRow,
isFirstColumn: position.isFirstColumn,
fullBleedImage: fullCardLayout,
);
// Everything the card closures capture; a change flushes the memo.
final cardEpoch = (ViewMode.grid, geometry.columnCount, totalSize, fullCardLayout, density);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalSize,
itemBuilder: (context, index) {
final playlist = loadedItems[index];
if (playlist == null) {
ensureIndexLoaded(index, pageSize: _pageSize);
return const SkeletonMediaCard();
}
final cached = _cardMemo.tryGet(index, playlist, epoch: cardEpoch);
if (cached != null) return cached;
// Budget fresh inflations while scrolling in pointer/touch mode
// (see CardInflationBudget); skeletons upgrade a frame later.
if (CardInflationBudget.isScrollingContext(context) &&
!InputModeTracker.isKeyboardMode(context) &&
!CardInflationBudget.tryTake()) {
scheduleSkeletonUpgrade();
return const SkeletonMediaCard();
}
return _cardMemo.widgetFor(
index,
playlist,
epoch: cardEpoch,
build: () => _buildPlaylistCard(
index,
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
fullBleedImage: fullCardLayout,
),
);
},
);
},
),
),
);
},
);
}
@@ -13,17 +13,15 @@ import '../../../mixins/deletion_aware.dart';
import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart';
import '../../../services/settings_service.dart';
import '../../../utils/debouncer.dart';
import '../../../utils/deletion_notifier.dart';
import '../../../utils/global_key_utils.dart';
import '../../../utils/layout_constants.dart';
import '../../../utils/platform_detector.dart';
import '../../../utils/provider_extensions.dart';
import '../../../utils/watch_state_notifier.dart';
import '../../../widgets/hub_section.dart';
import '../../../widgets/settings_builder.dart';
import '../../../widgets/tv_browse_rail.dart';
import '../../../widgets/tv_spotlight_background.dart';
import '../../../widgets/tv_spotlight_scaffold.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart';
@@ -51,43 +49,13 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
/// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
// TvSpotlightBackground subtree, never the rail/rows.
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
// Settle delay so d-pad scrubbing across a row doesn't fetch/decode a
// full-screen backdrop for every intermediate item.
final Debouncer _spotlightDebouncer = Debouncer(const Duration(milliseconds: 150));
final TvSpotlightController _spotlight = TvSpotlightController();
MediaItem? get _defaultSpotlightItem {
for (final hub in items) {
if (hub.items.isNotEmpty) return hub.items.first;
}
return null;
}
MediaItem? get _effectiveSpotlightItem {
final current = _spotlightItem.value;
if (current == null) return _defaultSpotlightItem;
for (final hub in items) {
if (hub.items.any((item) => item.globalKey == current.globalKey)) return current;
}
return _defaultSpotlightItem;
}
void _setSpotlightItem(MediaItem item) {
// Same-key check lives inside the callback: an A→B→A scrub must cancel
// the pending B, not early-return and let it fire.
_spotlightDebouncer.run(() {
if (!mounted) return;
if (_spotlightItem.value?.globalKey == item.globalKey) return;
_spotlightItem.value = item;
});
}
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
@override
void dispose() {
_spotlightDebouncer.dispose();
_spotlightItem.dispose();
_spotlight.dispose();
super.dispose();
}
@@ -359,99 +327,33 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
Widget _buildTvContent(List<MediaHub> items) {
final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList();
final size = MediaQuery.sizeOf(context);
final theme = Theme.of(context);
final svc = SettingsService.instance;
final scale = TvLayoutConstants.scaleForSize(size);
// Only layout-aspect (flip-stable) scope values may be read here: an
// offset-aspect read at this level would rebuild the whole screen on
// every sidebar focus flip. Offset values are read in small Builders
// around the widgets that position against them.
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
final railHeight = tvHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: railSize,
hubs: tvHubs,
density: svc.read(SettingsService.libraryDensity),
episodePosterMode: svc.read(SettingsService.episodePosterMode),
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: theme.scaffoldBackgroundColor,
child: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
// The animated -bleed mirrors the content-slide tween in
// MainScreen, keeping the full-bleed background viewport-pinned
// while the content box slides during sidebar expansion. The
// Builder scopes the offset-aspect dependency to this subtree.
Builder(
builder: (context) {
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
return SideNavigationBleedBuilder(
targetBleed: foregroundLeft,
child: ValueListenableBuilder<MediaItem?>(
valueListenable: _spotlightItem,
builder: (context, _, _) {
final spotlight = _effectiveSpotlightItem;
final client = context.tryGetMediaClientForServer(
serverIdOrNull(spotlight?.serverId ?? widget.library.serverId),
);
return TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + foregroundLeft,
compact: true,
showPrimaryAction: false,
);
},
),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
);
},
),
if (tvHubs.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
key: _tvBrowseRailKey,
hubs: tvHubs,
iconForHub: (hub, _) => _getHubIcon(hub),
onFocusedItemChanged: _setSpotlightItem,
onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching,
isContinueWatchingHub: _isContinueWatchingHub,
usesContinueWatchingAction: _usesContinueWatchingAction,
onNavigateUp: widget.onNavigateToChrome ?? widget.onBack,
onNavigateToSidebar: _navigateToSidebar,
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
return TvSpotlightScaffold(
hubs: tvHubs,
spotlightListenable: _spotlight,
resolveSpotlight: () => _spotlight.resolve(tvHubs),
resolveClient: (spotlight) =>
context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId ?? widget.library.serverId)),
foreground: tvHubs.isEmpty
? const SizedBox.shrink()
: Positioned(
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(
key: _tvBrowseRailKey,
hubs: tvHubs,
iconForHub: (hub, _) => _getHubIcon(hub),
onFocusedItemChanged: _setSpotlightItem,
onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching,
isContinueWatchingHub: _isContinueWatchingHub,
usesContinueWatchingAction: _usesContinueWatchingAction,
onNavigateUp: widget.onNavigateToChrome ?? widget.onBack,
onNavigateToSidebar: _navigateToSidebar,
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
),
],
),
),
),
);
}
+11 -836
View File
@@ -76,6 +76,10 @@ import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
part 'plex_client/parts/live_tv.dart';
part 'plex_client/parts/playlists.dart';
part 'plex_client/parts/collections.dart';
part 'plex_client/parts/play_queues.dart';
part 'plex_client/parts/metadata_edit.dart';
/// Result of a paginated library content fetch
class _LibraryContentResult {
@@ -208,7 +212,13 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) {
}
class PlexClient
with MediaServerCacheMixin, _PlexLiveTvClientMethods
with
MediaServerCacheMixin,
_PlexLiveTvClientMethods,
_PlexPlaylistMethods,
_PlexCollectionMethods,
_PlexPlayQueueMethods,
_PlexMetadataEditMethods
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
@override
PlexConfig config;
@@ -2083,717 +2093,6 @@ class PlexClient
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
}
/// Get playlist content by playlist ID, paginated.
Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
/// Get all playlists.
/// Filters by playlistType=video by default.
/// Set smart to true/false to filter smart playlists, or null for all.
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
try {
final all = <PlexPlaylistDto>[];
var start = 0;
while (true) {
final page = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: _fetchAllPageSize,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
start += page.items.length;
if (start >= page.totalSize) break;
}
return all;
} catch (e, st) {
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
return [];
}
}
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final pageSize = size ?? _defaultListContainerSize;
final queryParams = <String, dynamic>{
if (playlistType.isNotEmpty) 'playlistType': playlistType,
..._buildPaginationParams(start, pageSize),
};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
return _extractPlaylistListResult(response, start: start, size: pageSize);
}
/// Get playlist metadata by playlist ID
/// Returns the playlist details (not the items)
Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
try {
final response = await _getWithFailover('/playlists/$playlistId');
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to get playlist metadata: $e');
return null;
}
}
/// Neutral [MediaServerClient.createPlaylist] override — wraps
/// [createPlaylistFromUri] after building a Plex metadata URI from
/// the supplied items.
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
if (items.isEmpty) {
return createPlaylistFromUri(title: title);
}
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video');
}
/// Create a new playlist
/// [title] - Name of the playlist
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
/// [playQueueId] - Optional play queue ID to create playlist from
/// [type] - Plex playlist type ('video' or 'audio' for music items)
///
/// Errors propagate to the caller (matches the [MediaServerClient]
/// contract — throw on HTTP/transport failures, return `null` only when
/// the server replied 2xx but with no usable playlist payload).
Future<MediaPlaylist?> createPlaylistFromUri({
required String title,
String? uri,
int? playQueueId,
String type = 'video',
}) async {
final queryParams = <String, dynamic>{'type': type, 'title': title, 'smart': '0'};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playQueueId != null) {
queryParams['playQueueID'] = playQueueId.toString();
}
final response = await _http.post('/playlists', queryParameters: queryParams);
throwIfHttpError(response);
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
final dto = PlexPlaylistDto.fromJson(
metadata.first as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaPlaylist(dto);
}
/// Delete a playlist
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) {
return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
}
/// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex
/// metadata URI from [items] and delegates to [addItemsToPlaylistByUri].
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
}
/// Add items to a playlist
/// [playlistId] - The playlist to add items to
/// [uri] - Comma-separated list of item URIs to add
Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final result = await _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
'Failed to add to playlist',
);
if (result) {
appLogger.d('Add to playlist response status: 200');
}
return result;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false);
return _wrapBoolApiCall(
() => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
'Failed to remove from playlist',
);
}
/// Plex's `?after=0` sentinel means "move to the top". For any other index
/// the API needs the playlist-item id of the row that should sit immediately
/// before [item] after the move — that's what [afterItem] provides.
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! PlexMediaItem || item.playlistItemId == null) return false;
final int after;
if (newIndex == 0) {
after = 0;
} else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
after = afterItem.playlistItemId!;
} else {
return false;
}
appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId');
return _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
'Failed to move playlist item',
);
}
/// Update metadata fields for a media item
Future<bool> updateMetadata({
required int sectionId,
required String ratingKey,
required int typeNumber,
String? title,
String? titleSort,
String? originalTitle,
String? originallyAvailableAt,
String? contentRating,
String? studio,
String? tagline,
String? summary,
Map<String, ({List<String> current, List<String> original})>? tagChanges,
}) async {
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
void addField(String name, String? value) {
if (value != null) {
queryParams['$name.value'] = value;
queryParams['$name.locked'] = '1';
}
}
addField('title', title);
addField('titleSort', titleSort);
addField('originalTitle', originalTitle);
addField('originallyAvailableAt', originallyAvailableAt);
addField('contentRating', contentRating);
addField('studio', studio);
addField('tagline', tagline);
addField('summary', summary);
if (tagChanges != null) {
for (final entry in tagChanges.entries) {
final field = entry.key;
final current = entry.value.current;
final original = entry.value.original;
for (var i = 0; i < current.length; i++) {
queryParams['$field[$i].tag.tag'] = current[i];
}
final removed = original.where((t) => !current.contains(t)).toList();
if (removed.isNotEmpty) {
queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
}
queryParams['$field.locked'] = '1';
}
}
final result = await _wrapBoolApiCall(
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
'Failed to update metadata',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Search for match candidates for a media item.
Future<List<PlexMatchResult>> findMatches(
String ratingKey, {
String? title,
String? year,
String? agent,
String? language,
}) async {
final queryParams = <String, dynamic>{'manual': 1};
if (title != null && title.isNotEmpty) queryParams['title'] = title;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent;
if (language != null && language.isNotEmpty) queryParams['language'] = language;
return _wrapListApiCall<PlexMatchResult>(
() => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams),
(response) {
final container = _getMediaContainer(response);
if (container == null || container['SearchResult'] == null) return [];
return (container['SearchResult'] as List)
.map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>))
.toList();
},
'Failed to search for matches',
);
}
/// Apply a chosen match to a media item.
Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
final queryParams = <String, dynamic>{'guid': guid};
if (name != null && name.isNotEmpty) queryParams['name'] = name;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams),
'Failed to apply match',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<bool> unmatchItem(String ratingKey) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/unmatch'),
'Failed to unmatch item',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Get available artwork (posters or backgrounds) for a media item
Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List).cast<Map<String, dynamic>>();
}
return [];
} catch (e) {
appLogger.e('Failed to get available artwork', error: e);
return [];
}
}
/// Set artwork from a URL (can be a Plex internal path or external URL)
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
'Failed to set artwork from URL',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Upload artwork from binary data
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put(
'/library/metadata/$ratingKey/$setElement',
body: bytes,
headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
),
'Failed to upload artwork',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Update per-media advanced preferences
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
'Failed to update metadata preferences',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<void> _deleteMetadataEditCache(String ratingKey) async {
try {
await _cache.deleteForItem(serverId, ratingKey);
} catch (e, st) {
appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st);
}
}
/// Get one page of collections for a library section.
Future<_LibraryContentResult> _getLibraryCollectionsPage(
String sectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
final response = await _getWithFailover(
'/library/sections/$sectionId/collections',
queryParameters: queryParameters,
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: _librarySectionIdFromString(sectionId),
start: start,
requestedSize: size,
);
}
/// Get all collections for a library section.
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
try {
return _fetchAllPages((start, size, abort) {
return _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort);
});
} catch (e, st) {
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
return [];
}
}
/// Get items in a collection, paginated.
Future<_LibraryContentResult> _getCollectionItems(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? librarySectionID,
String? librarySectionTitle,
}) => _fetchPaginatedList(
'/library/collections/$collectionId/children',
start: start,
size: size,
abort: abort,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
/// Get media featuring a specific person (actor/director), paginated.
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
/// Fetch every media item featuring a given person.
Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) =>
_fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));
/// Delete a collection. Reads the section id from [collection.libraryId].
@override
Future<bool> deleteCollection(MediaItem collection) async {
final sectionId = collection.libraryId ?? '';
return deleteCollectionById(sectionId, collection.id);
}
Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId'),
'Failed to delete collection',
);
if (result) {
appLogger.d('Delete collection response: 200');
}
return result;
}
/// Neutral [MediaServerClient.createCollection] — builds a Plex metadata
/// URI for [items] and maps [itemKind] to Plex's section type id.
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(','));
final type = switch (itemKind) {
MediaKind.movie => 1,
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
_ => null,
};
return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
}
/// Create a new collection
/// Creates a new collection and optionally adds items to it
/// Returns the created collection ID or null if failed
Future<String?> createCollectionFromUri({
required String sectionId,
required String title,
required String uri,
int? type,
}) async {
try {
appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
final response = await _http.post(
'/library/collections',
queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
);
throwIfHttpError(response);
appLogger.d('Create collection response: ${response.statusCode}');
// Extract the collection ID from the response
// The response should contain the created collection metadata
final container = _getMediaContainer(response);
if (container != null) {
final metadata = container['Metadata'];
if (metadata != null && (metadata as List).isNotEmpty) {
final collectionId = metadata.first['ratingKey']?.toString();
appLogger.d('Created collection with ID: $collectionId');
return collectionId;
}
}
return null;
} catch (e) {
appLogger.e('Failed to create collection', error: e);
return null;
}
}
/// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI
/// from [items] and delegates to [addItemsToCollectionByUri].
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
}
/// Add items to an existing collection
/// Adds one or more items (specified by URI) to an existing collection
Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
appLogger.d('Adding items to collection: collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
'Failed to add items to collection',
);
if (result) {
appLogger.d('Add to collection response: 200');
}
return result;
}
/// Remove an item from a collection
/// Removes a single item from an existing collection
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId/items/${item.id}'),
'Failed to remove item from collection',
);
if (result) {
appLogger.d('Remove from collection response: 200');
}
return result;
}
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
/// MediaItem-typed entries.
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
final container = data is Map && data['MediaContainer'] is Map
? data['MediaContainer'] as Map<String, dynamic>
: data as Map<String, dynamic>;
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
final metadata = container['Metadata'];
List<MediaItem>? items;
if (metadata is List) {
items = [
for (final e in metadata)
if (e is Map<String, dynamic>)
PlexMappers.mediaItem(
_createTaggedMetadataWithLibrary(
e,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
),
];
}
final playQueueID = flexibleInt(container['playQueueID']);
final playQueueVersion = flexibleInt(container['playQueueVersion']);
if (playQueueID == null || playQueueVersion == null) {
throw const FormatException('Plex play queue response is missing its numeric id or version');
}
return PlayQueueResponse(
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
}
/// Create a new play queue
/// Either uri or playlistID must be specified
Future<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'type': type,
'shuffle': shuffle,
'repeat': repeat,
'continuous': continuous,
};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playlistID != null) {
queryParams['playlistID'] = playlistID;
}
if (key != null) {
queryParams['key'] = key;
}
final response = await _http.post('/playQueues', queryParameters: queryParams);
throwIfHttpError(response);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create play queue', error: e);
return null;
}
}
/// Get a play queue with optional windowing
/// Can request a window of items around a specific item
Future<PlayQueueResponse?> getPlayQueue(
int playQueueId, {
String? center,
int window = 50,
int includeBefore = 1,
int includeAfter = 1,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'window': window,
'includeBefore': includeBefore,
'includeAfter': includeAfter,
};
if (center != null) {
queryParams['center'] = center;
}
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to get play queue: $e');
return null;
}
}
/// Create a play queue for a TV show (all episodes)
///
/// This is a convenience method that creates a play queue from a show's URI.
/// Perfect for sequential or shuffle playback of an entire series.
///
/// Parameters:
/// - [showRatingKey]: The rating key of the show
/// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
/// - [startingEpisodeKey]: Optional rating key of episode to start from
///
/// Returns a PlayQueueResponse with all episodes from the show
Future<PlayQueueResponse?> createShowPlayQueue({
required String showRatingKey,
int shuffle = 0,
String? startingEpisodeKey,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
// Build the queue from the show's `/allLeaves` (every episode) rather than
// `/children` (its seasons). Plex flattens `/children` season-by-season,
// which clumps the whole Specials folder together; `/allLeaves` makes Plex
// order the queue by the show's aired episode order, so Specials interleave
// between regular episodes the way Plex's own client plays them. `/children`
// otherwise strands interleaved Specials ahead of S01, so sequential
// auto-play walks the season and never reaches them (#1416).
final uri = '${await buildMetadataUri(showRatingKey)}/allLeaves';
return await createPlayQueue(
uri: uri,
type: 'video',
shuffle: shuffle,
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create show play queue', error: e);
return null;
}
}
/// Extract both Metadata and Directory entries from response
/// Folders can come back as either type
/// Automatically tags all items with this client's serverId and serverName
@@ -4090,130 +3389,6 @@ class PlexClient
throw UnsupportedError('Plex does not support user favorites.');
}
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
}
@override
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: size,
abort: abort,
);
return LibraryPage<MediaPlaylist>(
items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final p = await _getPlaylistMetadata(id);
return p == null ? null : PlexMappers.mediaPlaylist(p);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final page = await fetchPlaylistPage(id, start: offset, size: limit);
return page.items;
}
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
String playlistId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final raw = await _getLibraryCollections(libraryId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
String libraryId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
final result = await _getCollectionItems(
collectionId,
start: start,
size: size,
abort: abort,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
String personId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);
/// Plex-specific: full person-media listing across pages.
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
final raw = await _fetchAllPersonMediaDto(personId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Plex-specific: hub content as neutral [MediaItem]s.
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
final raw = await _getHubContent(hubKey);
@@ -0,0 +1,262 @@
part of '../../plex_client.dart';
mixin _PlexCollectionMethods on MediaServerCacheMixin {
FailoverHttpClient get _http;
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
Map<String, dynamic> _buildPaginationParams(int? start, int? size);
_LibraryContentResult _extractLibraryContentResult(
MediaServerResponse response, {
int? librarySectionID,
String? librarySectionTitle,
int? start,
int? requestedSize,
});
Future<_LibraryContentResult> _fetchPaginatedList(
String path, {
int? start,
int? size,
AbortController? abort,
int? librarySectionID,
String? librarySectionTitle,
});
Future<List<PlexMetadataDto>> _fetchAllPages(
Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
AbortController? abort,
});
Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage);
Future<String> buildMetadataUri(String ratingKey);
Future<_LibraryContentResult> _getLibraryCollectionsPage(
String sectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
final response = await _getWithFailover(
'/library/sections/$sectionId/collections',
queryParameters: queryParameters,
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: _librarySectionIdFromString(sectionId),
start: start,
requestedSize: size,
);
}
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
try {
return _fetchAllPages(
(start, size, abort) => _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort),
);
} catch (e, st) {
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
return [];
}
}
Future<_LibraryContentResult> _getCollectionItems(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? librarySectionID,
String? librarySectionTitle,
}) => _fetchPaginatedList(
'/library/collections/$collectionId/children',
start: start,
size: size,
abort: abort,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) {
return _fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));
}
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final raw = await _getLibraryCollections(libraryId);
return raw.map(PlexMappers.mediaItem).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
String libraryId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map(PlexMappers.mediaItem).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
final result = await _getCollectionItems(
collectionId,
start: start,
size: size,
abort: abort,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
return LibraryPage<MediaItem>(
items: result.items.map(PlexMappers.mediaItem).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
String personId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map(PlexMappers.mediaItem).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchPersonMedia(String personId) {
return fetchAllPersonMediaAsMediaItems(personId);
}
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
final raw = await _fetchAllPersonMediaDto(personId);
return raw.map(PlexMappers.mediaItem).toList();
}
@override
Future<bool> deleteCollection(MediaItem collection) {
return deleteCollectionById(collection.libraryId ?? '', collection.id);
}
Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
appLogger.d(
'Deleting collection: sectionId=$sectionId, '
'collectionId=$collectionId',
);
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId'),
'Failed to delete collection',
);
if (result) appLogger.d('Delete collection response: 200');
return result;
}
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((item) => item.id).join(','));
final type = switch (itemKind) {
MediaKind.movie => 1,
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
_ => null,
};
return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
}
Future<String?> createCollectionFromUri({
required String sectionId,
required String title,
required String uri,
int? type,
}) async {
try {
appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
final response = await _http.post(
'/library/collections',
queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
);
throwIfHttpError(response);
appLogger.d('Create collection response: ${response.statusCode}');
final metadata = _getMediaContainer(response)?['Metadata'];
if (metadata is List && metadata.isNotEmpty) {
final collectionId = metadata.first['ratingKey']?.toString();
appLogger.d('Created collection with ID: $collectionId');
return collectionId;
}
return null;
} catch (e) {
appLogger.e('Failed to create collection', error: e);
return null;
}
}
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((item) => item.id).join(','));
return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
}
Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
appLogger.d('Adding items to collection: collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
'Failed to add items to collection',
);
if (result) appLogger.d('Add to collection response: 200');
return result;
}
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
appLogger.d(
'Removing item from collection: collectionId=$collectionId, '
'itemId=${item.id}',
);
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId/items/${item.id}'),
'Failed to remove item from collection',
);
if (result) appLogger.d('Remove from collection response: 200');
return result;
}
}
@@ -0,0 +1,185 @@
part of '../../plex_client.dart';
mixin _PlexMetadataEditMethods on MediaServerCacheMixin {
FailoverHttpClient get _http;
PlexApiCache get _cache;
ServerId get serverId;
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage);
Future<List<T>> _wrapListApiCall<T>(
Future<MediaServerResponse> Function() apiCall,
List<T> Function(MediaServerResponse response) parseResponse,
String errorMessage,
);
Future<bool> updateMetadata({
required int sectionId,
required String ratingKey,
required int typeNumber,
String? title,
String? titleSort,
String? originalTitle,
String? originallyAvailableAt,
String? contentRating,
String? studio,
String? tagline,
String? summary,
Map<String, ({List<String> current, List<String> original})>? tagChanges,
}) async {
final queryParameters = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
void addField(String name, String? value) {
if (value == null) return;
queryParameters['$name.value'] = value;
queryParameters['$name.locked'] = '1';
}
addField('title', title);
addField('titleSort', titleSort);
addField('originalTitle', originalTitle);
addField('originallyAvailableAt', originallyAvailableAt);
addField('contentRating', contentRating);
addField('studio', studio);
addField('tagline', tagline);
addField('summary', summary);
if (tagChanges != null) {
for (final entry in tagChanges.entries) {
final field = entry.key;
final current = entry.value.current;
final original = entry.value.original;
for (var index = 0; index < current.length; index++) {
queryParameters['$field[$index].tag.tag'] = current[index];
}
final removed = original.where((tag) => !current.contains(tag));
if (removed.isNotEmpty) {
queryParameters['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
}
queryParameters['$field.locked'] = '1';
}
}
final result = await _wrapBoolApiCall(
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParameters),
'Failed to update metadata',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
Future<List<PlexMatchResult>> findMatches(
String ratingKey, {
String? title,
String? year,
String? agent,
String? language,
}) {
final queryParameters = <String, dynamic>{
'manual': 1,
if (title != null && title.isNotEmpty) 'title': title,
if (year != null && year.isNotEmpty) 'year': year,
if (agent != null && agent.isNotEmpty) 'agent': agent,
if (language != null && language.isNotEmpty) 'language': language,
};
return _wrapListApiCall<PlexMatchResult>(
() => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParameters),
(response) {
final results = _getMediaContainer(response)?['SearchResult'];
if (results is! List) return [];
return results.map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>)).toList();
},
'Failed to search for matches',
);
}
Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
final queryParameters = <String, dynamic>{
'guid': guid,
if (name != null && name.isNotEmpty) 'name': name,
if (year != null && year.isNotEmpty) 'year': year,
};
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParameters),
'Failed to apply match',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
Future<bool> unmatchItem(String ratingKey) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/unmatch'),
'Failed to unmatch item',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
final metadata = _getMediaContainer(response)?['Metadata'];
return metadata is List ? metadata.cast<Map<String, dynamic>>() : const [];
} catch (e) {
appLogger.e('Failed to get available artwork', error: e);
return [];
}
}
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) async {
final target = _artworkTarget(element);
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/$target', queryParameters: {'url': url}),
'Failed to set artwork from URL',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) async {
final target = _artworkTarget(element);
final result = await _wrapBoolApiCall(
() => _http.put(
'/library/metadata/$ratingKey/$target',
body: bytes,
headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
),
'Failed to upload artwork',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
'Failed to update metadata preferences',
);
if (result) await _deleteMetadataEditCache(ratingKey);
return result;
}
String _artworkTarget(String element) {
return element.endsWith('s') ? element.substring(0, element.length - 1) : element;
}
Future<void> _deleteMetadataEditCache(String ratingKey) async {
try {
await _cache.deleteForItem(serverId, ratingKey);
} catch (e, st) {
appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st);
}
}
}
@@ -0,0 +1,150 @@
part of '../../plex_client.dart';
mixin _PlexPlayQueueMethods on MediaServerCacheMixin {
FailoverHttpClient get _http;
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
});
PlexMetadataDto _createTaggedMetadataWithLibrary(
Map<String, dynamic> json, {
int? librarySectionID,
String? librarySectionTitle,
});
Future<String> buildMetadataUri(String ratingKey);
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
final container = data is Map && data['MediaContainer'] is Map
? data['MediaContainer'] as Map<String, dynamic>
: data as Map<String, dynamic>;
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
final metadata = container['Metadata'];
List<MediaItem>? items;
if (metadata is List) {
items = [
for (final entry in metadata)
if (entry is Map<String, dynamic>)
PlexMappers.mediaItem(
_createTaggedMetadataWithLibrary(
entry,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
),
];
}
final playQueueID = flexibleInt(container['playQueueID']);
final playQueueVersion = flexibleInt(container['playQueueVersion']);
if (playQueueID == null || playQueueVersion == null) {
throw const FormatException('Plex play queue response is missing its numeric id or version');
}
return PlayQueueResponse(
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
}
Future<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParameters = <String, dynamic>{
'type': type,
'shuffle': shuffle,
'repeat': repeat,
'continuous': continuous,
if (uri != null) 'uri': uri,
if (playlistID != null) 'playlistID': playlistID,
if (key != null) 'key': key,
};
final response = await _http.post('/playQueues', queryParameters: queryParameters);
throwIfHttpError(response);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create play queue', error: e);
return null;
}
}
Future<PlayQueueResponse?> getPlayQueue(
int playQueueId, {
String? center,
int window = 50,
int includeBefore = 1,
int includeAfter = 1,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParameters = <String, dynamic>{
'window': window,
'includeBefore': includeBefore,
'includeAfter': includeAfter,
if (center != null) 'center': center,
};
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParameters);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to get play queue: $e');
return null;
}
}
Future<PlayQueueResponse?> createShowPlayQueue({
required String showRatingKey,
int shuffle = 0,
String? startingEpisodeKey,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
// `/allLeaves` preserves Plex's aired episode order and interleaves
// specials; `/children` groups specials into a separate season.
final uri = '${await buildMetadataUri(showRatingKey)}/allLeaves';
return createPlayQueue(
uri: uri,
type: 'video',
shuffle: shuffle,
key: startingEpisodeKey == null ? null : '/library/metadata/$startingEpisodeKey',
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create show play queue', error: e);
return null;
}
}
}
@@ -0,0 +1,240 @@
part of '../../plex_client.dart';
mixin _PlexPlaylistMethods on MediaServerCacheMixin {
static const int _playlistPageSize = 200;
static const int _defaultPlaylistContainerSize = 100;
FailoverHttpClient get _http;
ServerId get serverId;
String? get serverName;
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
});
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response);
Map<String, dynamic> _buildPaginationParams(int? start, int? size);
Future<_LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort});
({List<PlexPlaylistDto> items, int totalSize}) _extractPlaylistListResult(
MediaServerResponse response, {
int? start,
int? size,
});
Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage);
Future<String> buildMetadataUri(String ratingKey);
Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
try {
final all = <PlexPlaylistDto>[];
var start = 0;
while (true) {
final page = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: _playlistPageSize,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
start += page.items.length;
if (start >= page.totalSize) break;
}
return all;
} catch (e, st) {
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
return [];
}
}
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final pageSize = size ?? _defaultPlaylistContainerSize;
final queryParams = <String, dynamic>{
if (playlistType.isNotEmpty) 'playlistType': playlistType,
..._buildPaginationParams(start, pageSize),
};
if (smart != null) queryParams['smart'] = smart ? '1' : '0';
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
return _extractPlaylistListResult(response, start: start, size: pageSize);
}
Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
try {
final response = await _getWithFailover('/playlists/$playlistId');
final container = _getMediaContainer(response);
final metadata = container?['Metadata'];
if (metadata is! List || metadata.isEmpty) return null;
return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to get playlist metadata: $e');
return null;
}
}
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
return playlists.map(PlexMappers.mediaPlaylist).toList();
}
@override
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: size,
abort: abort,
);
return LibraryPage<MediaPlaylist>(
items: result.items.map(PlexMappers.mediaPlaylist).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final playlist = await _getPlaylistMetadata(id);
return playlist == null ? null : PlexMappers.mediaPlaylist(playlist);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final page = await fetchPlaylistPage(id, start: offset, size: limit);
return page.items;
}
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
String playlistId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map(PlexMappers.mediaItem).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
if (items.isEmpty) return createPlaylistFromUri(title: title);
final uri = await buildMetadataUri(items.map((item) => item.id).join(','));
return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video');
}
Future<MediaPlaylist?> createPlaylistFromUri({
required String title,
String? uri,
int? playQueueId,
String type = 'video',
}) async {
final queryParameters = <String, dynamic>{
'type': type,
'title': title,
'smart': '0',
if (uri != null) 'uri': uri,
if (playQueueId != null) 'playQueueID': playQueueId.toString(),
};
final response = await _http.post('/playlists', queryParameters: queryParameters);
throwIfHttpError(response);
final metadata = _getMediaContainer(response)?['Metadata'];
if (metadata is! List || metadata.isEmpty) return null;
final dto = PlexPlaylistDto.fromJson(
metadata.first as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaPlaylist(dto);
}
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) {
return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
}
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((item) => item.id).join(','));
return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
}
Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
appLogger.d(
'Adding to playlist $playlistId with URI: '
'${uri.substring(0, uri.length > 100 ? 100 : uri.length)}'
'${uri.length > 100 ? "..." : ""}',
);
final result = await _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
'Failed to add to playlist',
);
if (result) appLogger.d('Add to playlist response status: 200');
return result;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
if (item is! PlexMediaItem || item.playlistItemId == null) {
return Future.value(false);
}
return _wrapBoolApiCall(
() => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
'Failed to remove from playlist',
);
}
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! PlexMediaItem || item.playlistItemId == null) return false;
final int after;
if (newIndex == 0) {
after = 0;
} else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
after = afterItem.playlistItemId!;
} else {
return false;
}
appLogger.d(
'Moving playlist item ${item.playlistItemId} after $after in playlist '
'$playlistId',
);
return _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
'Failed to move playlist item',
);
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../media/media_item.dart';
import '../services/settings_service.dart';
import 'media_grid_delegate.dart';
import 'sliver_cross_axis_layout_builder.dart';
@immutable
class MediaCardSliverPosition {
const MediaCardSliverPosition({
required this.index,
required this.itemCount,
required this.columnCount,
required this.isGrid,
this.layoutEpoch,
});
final int index;
final int itemCount;
final int columnCount;
final bool isGrid;
final Object? layoutEpoch;
bool get isFirstRow => index < columnCount;
bool get isFirstColumn => index % columnCount == 0;
bool get isLastColumn => index % columnCount == columnCount - 1;
bool get disableScale => !isGrid;
}
typedef MediaCardSliverItemBuilder = Widget Function(BuildContext context, MediaCardSliverPosition position);
/// Shared list/grid sliver switch for media-card surfaces.
///
/// Consumers retain card construction and focus policy while this widget owns
/// the identical sliver wrappers, grid geometry, and delegate configuration.
class MediaCardSliverLayout extends StatelessWidget {
const MediaCardSliverLayout({
super.key,
required this.viewMode,
required this.itemCount,
required this.density,
required this.padding,
required this.itemBuilder,
this.fullBleedImage = false,
this.useWideAspectRatio = false,
this.shape,
this.usePaddingAware = false,
this.horizontalPadding = 0,
this.crossAxisExtentForColumnCount,
this.onGridGeometry,
this.listEpoch,
this.gridEpochBuilder,
});
final ViewMode viewMode;
final int itemCount;
final int density;
final EdgeInsetsGeometry padding;
final MediaCardSliverItemBuilder itemBuilder;
final bool fullBleedImage;
final bool useWideAspectRatio;
final CardShape? shape;
final bool usePaddingAware;
final double horizontalPadding;
final double? Function(double crossAxisExtent)? crossAxisExtentForColumnCount;
final ValueChanged<MediaGridGeometry>? onGridGeometry;
final Object? listEpoch;
final Object? Function(MediaGridGeometry geometry)? gridEpochBuilder;
@override
Widget build(BuildContext context) {
if (viewMode == ViewMode.list) {
return SliverPadding(
padding: padding,
sliver: SliverList.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: itemCount,
itemBuilder: (context, index) => itemBuilder(
context,
MediaCardSliverPosition(
index: index,
itemCount: itemCount,
columnCount: 1,
isGrid: false,
layoutEpoch: listEpoch,
),
),
),
);
}
return SliverPadding(
padding: padding,
sliver: SliverCrossAxisLayoutBuilder(
builder: (context, crossAxisExtent) {
final geometry = MediaGridGeometry.resolve(
context: context,
crossAxisExtent: crossAxisExtent,
crossAxisExtentForColumnCount: crossAxisExtentForColumnCount?.call(crossAxisExtent),
density: density,
useWideAspectRatio: useWideAspectRatio,
fullBleedImage: fullBleedImage,
shape: shape,
usePaddingAware: usePaddingAware,
horizontalPadding: horizontalPadding,
);
onGridGeometry?.call(geometry);
final layoutEpoch = gridEpochBuilder?.call(geometry);
return SliverGrid.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: itemCount,
itemBuilder: (context, index) => itemBuilder(
context,
MediaCardSliverPosition(
index: index,
itemCount: itemCount,
columnCount: geometry.columnCount,
isGrid: true,
layoutEpoch: layoutEpoch,
),
),
);
},
),
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../navigation/main_screen_scope.dart';
import '../services/settings_service.dart';
import '../utils/debouncer.dart';
import '../utils/layout_constants.dart';
import 'tv_browse_rail.dart';
import 'tv_spotlight_background.dart';
class TvSpotlightController extends ValueNotifier<MediaItem?> {
TvSpotlightController({Duration settleDelay = const Duration(milliseconds: 150)})
: _settleDelay = settleDelay,
_debouncer = Debouncer(settleDelay),
super(null);
final Duration _settleDelay;
final Debouncer _debouncer;
void select(MediaItem item) {
void apply() {
if (value?.globalKey == item.globalKey) return;
value = item;
}
if (_settleDelay == Duration.zero) {
apply();
} else {
_debouncer.run(apply);
}
}
MediaItem? resolve(Iterable<MediaHub> hubs) {
MediaItem? fallback;
final current = value;
for (final hub in hubs) {
if (hub.items.isEmpty) continue;
fallback ??= hub.items.first;
if (current == null) continue;
for (final item in hub.items) {
if (item.globalKey == current.globalKey) return current;
}
}
return fallback;
}
@override
void dispose() {
_debouncer.dispose();
super.dispose();
}
}
typedef TvSpotlightClientResolver = MediaServerClient? Function(MediaItem? item);
/// Shared full-screen TV backdrop and foreground stack used by hub rails.
class TvSpotlightScaffold extends StatelessWidget {
const TvSpotlightScaffold({
super.key,
required this.hubs,
required this.spotlightListenable,
required this.resolveSpotlight,
required this.resolveClient,
required this.foreground,
this.hideSpoilers,
});
final List<MediaHub> hubs;
final ValueListenable<MediaItem?> spotlightListenable;
final MediaItem? Function() resolveSpotlight;
final TvSpotlightClientResolver resolveClient;
final Widget foreground;
final bool? hideSpoilers;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final settings = SettingsService.instance;
final scale = TvLayoutConstants.scaleForSize(size);
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
final railHeight = hubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
size: railSize,
hubs: hubs,
density: settings.read(SettingsService.libraryDensity),
episodePosterMode: settings.read(SettingsService.episodePosterMode),
fullCardLayout: settings.read(SettingsService.tvFullCardLayout),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
Builder(
builder: (context) {
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
return SideNavigationBleedBuilder(
targetBleed: foregroundLeft,
child: ValueListenableBuilder<MediaItem?>(
valueListenable: spotlightListenable,
builder: (context, _, _) {
final spotlight = resolveSpotlight();
return TvSpotlightBackground(
item: spotlight,
client: resolveClient(spotlight),
hideSpoilers: hideSpoilers ?? settings.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + foregroundLeft,
compact: true,
showPrimaryAction: false,
);
},
),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
);
},
),
foreground,
],
),
),
);
}
}
+116 -83
View File
@@ -55,11 +55,12 @@ bool MpvPlayer::Initialize() {
mpv_set_option_string(mpv_, "hwdec", "auto");
}
mpv_set_option_string(mpv_, "keep-open", "yes");
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
if (!audio_only_) {
// HDR tone mapping
mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "target-colorspace-hint", "no");
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
}
mpv_set_option_string(mpv_, "idle", "yes");
@@ -82,6 +83,8 @@ bool MpvPlayer::Initialize() {
// Set up event wakeup callback.
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this);
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
mpv_observe_property(mpv_, 0, "audio-device-list", MPV_FORMAT_NONE);
g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
return true;
@@ -219,24 +222,12 @@ void MpvPlayer::Dispose() {
event_callback_ = nullptr;
}
// 4. Cancel pending async requests
std::vector<StatusCallback> status_callbacks;
std::vector<GetPropertyCallback> get_callbacks;
{
std::lock_guard<std::mutex> request_lock(pending_requests_mutex_);
for (auto& pair : pending_status_requests_) {
if (pair.second) status_callbacks.push_back(std::move(pair.second));
}
for (auto& pair : pending_get_property_requests_) {
if (pair.second) get_callbacks.push_back(std::move(pair.second));
}
pending_status_requests_.clear();
pending_get_property_requests_.clear();
}
for (auto& callback : status_callbacks) {
// 4. Cancel pending async requests.
auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) {
callback(-1);
}
for (auto& callback : get_callbacks) {
for (auto& callback : cancelled.properties) {
callback(-1, "");
}
@@ -245,6 +236,10 @@ void MpvPlayer::Dispose() {
g_source_remove(event_source_id_);
event_source_id_ = 0;
}
if (recovery_source_id_ != 0) {
g_source_remove(recovery_source_id_);
recovery_source_id_ = 0;
}
// 6. Free render context and mpv handle in a background thread.
// mpv_render_context_free() can block waiting for mpv's render/VO thread,
@@ -276,7 +271,7 @@ void MpvPlayer::Dispose() {
}
}).detach();
observed_properties_.clear();
observed_properties_.Clear();
}
void MpvPlayer::Render(int width, int height, int fbo) {
@@ -315,11 +310,11 @@ void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallba
}
c_args.push_back(nullptr);
uint64_t request_id = callback ? RegisterStatusRequest(std::move(callback)) : 0;
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
int result = mpv_command_async(mpv_, request_id, c_args.data());
if (result < 0) {
auto cb = TakeStatusRequest(request_id);
auto cb = pending_requests_.TakeStatus(request_id);
if (cb) cb(result);
}
}
@@ -334,12 +329,17 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
return;
}
uint64_t request_id = callback ? RegisterStatusRequest(std::move(callback)) : 0;
if (name == "hdr-enabled") {
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
return;
}
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
char* property_value = const_cast<char*>(value.c_str());
int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value);
if (result < 0) {
auto cb = TakeStatusRequest(request_id);
auto cb = pending_requests_.TakeStatus(request_id);
if (cb) cb(result);
}
}
@@ -350,72 +350,21 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca
return;
}
uint64_t request_id = RegisterGetPropertyRequest(std::move(callback));
uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback));
int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING);
if (result < 0) {
auto cb = TakeGetPropertyRequest(request_id);
auto cb = pending_requests_.TakeProperty(request_id);
if (cb) cb(result, "");
}
}
uint64_t MpvPlayer::RegisterStatusRequest(StatusCallback callback) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
uint64_t request_id = next_reply_userdata_++;
pending_status_requests_[request_id] = std::move(callback);
return request_id;
}
MpvPlayer::StatusCallback MpvPlayer::TakeStatusRequest(uint64_t request_id) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
auto it = pending_status_requests_.find(request_id);
if (it == pending_status_requests_.end()) return nullptr;
auto callback = std::move(it->second);
pending_status_requests_.erase(it);
return callback;
}
uint64_t MpvPlayer::RegisterGetPropertyRequest(GetPropertyCallback callback) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
uint64_t request_id = next_reply_userdata_++;
pending_get_property_requests_[request_id] = std::move(callback);
return request_id;
}
MpvPlayer::GetPropertyCallback MpvPlayer::TakeGetPropertyRequest(uint64_t request_id) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
auto it = pending_get_property_requests_.find(request_id);
if (it == pending_get_property_requests_.end()) return nullptr;
auto callback = std::move(it->second);
pending_get_property_requests_.erase(it);
return callback;
}
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
if (disposed_ || !mpv_) return;
if (observed_properties_.find(name) != observed_properties_.end()) {
return;
}
name_to_id_[name] = id;
mpv_format mpv_fmt = MPV_FORMAT_NONE;
if (format == "string") {
mpv_fmt = MPV_FORMAT_STRING;
} else if (format == "flag" || format == "bool") {
mpv_fmt = MPV_FORMAT_FLAG;
} else if (format == "int64") {
mpv_fmt = MPV_FORMAT_INT64;
} else if (format == "double") {
mpv_fmt = MPV_FORMAT_DOUBLE;
} else if (format == "node") {
mpv_fmt = MPV_FORMAT_NODE;
}
uint64_t userdata = next_reply_userdata_++;
observed_properties_[name] = userdata;
mpv_observe_property(mpv_, userdata, name.c_str(), mpv_fmt);
const auto request = observed_properties_.Register(name, format, id);
if (!request.added) return;
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
}
void MpvPlayer::ReportMouseMove(int x, int y) {
@@ -503,12 +452,65 @@ bool MpvPlayer::ProcessEvents() {
return true;
}
void MpvPlayer::LogRecovery(const std::string& text) {
g_warning("MPV audio-recovery: %s", text.c_str());
FlValue* data = fl_value_new_map();
fl_value_set_string_take(data, "prefix", fl_value_new_string("audio-recovery"));
fl_value_set_string_take(data, "level", fl_value_new_string("warn"));
fl_value_set_string_take(data, "text", fl_value_new_string(text.c_str()));
SendEvent("log-message", data);
fl_value_unref(data);
}
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
const std::string reason_copy = reason;
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
audio_recovery_.CompleteReload();
LogRecovery(
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
", error=" + std::to_string(error) + ")");
});
}
void MpvPlayer::MaybeRunAudioRecovery() {
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
return;
}
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
TryAudioReload(reason, action.attempt);
if (action.exhausted) {
LogRecovery("audio recovery budget exhausted; waiting for device list change");
}
}
void MpvPlayer::EnsureAudioRecoveryTimer() {
if (recovery_source_id_ != 0 || !audio_recovery_.HasPendingWork()) return;
recovery_source_id_ = g_timeout_add(
100,
[](gpointer data) -> gboolean {
auto* player = static_cast<MpvPlayer*>(data);
if (player->disposed_) {
player->recovery_source_id_ = 0;
return G_SOURCE_REMOVE;
}
player->MaybeRunAudioRecovery();
if (!player->audio_recovery_.HasPendingWork()) {
player->recovery_source_id_ = 0;
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
},
this);
}
void MpvPlayer::HandleMpvEvent(mpv_event* event) {
switch (event->event_id) {
case MPV_EVENT_COMMAND_REPLY:
case MPV_EVENT_SET_PROPERTY_REPLY: {
uint64_t request_id = event->reply_userdata;
StatusCallback callback = TakeStatusRequest(request_id);
StatusCallback callback = pending_requests_.TakeStatus(request_id);
if (callback) {
int error = event->error;
g_idle_add(
@@ -524,7 +526,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
}
case MPV_EVENT_GET_PROPERTY_REPLY: {
uint64_t request_id = event->reply_userdata;
GetPropertyCallback callback = TakeGetPropertyRequest(request_id);
GetPropertyCallback callback = pending_requests_.TakeProperty(request_id);
if (callback) {
int error = event->error;
std::string value;
@@ -587,10 +589,32 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
break;
}
if (strcmp(prop->name, "current-ao") == 0) {
const char* current_ao = nullptr;
if (prop->format == MPV_FORMAT_STRING && prop->data) {
current_ao = *static_cast<char**>(prop->data);
}
const bool is_null = current_ao && strcmp(current_ao, "null") == 0;
const auto transition =
audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now());
if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) {
LogRecovery("current-ao fell back to null; starting recovery");
EnsureAudioRecoveryTimer();
} else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) {
LogRecovery("audio recovered (current-ao no longer null)");
}
}
if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 &&
audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) {
LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload");
EnsureAudioRecoveryTimer();
}
SendPropertyChange(prop->name, &node);
break;
}
case MPV_EVENT_END_FILE: {
audio_recovery_.SetFileLoaded(false);
auto* end = static_cast<mpv_event_end_file*>(event->data);
FlValue* data = fl_value_new_map();
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
@@ -604,6 +628,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
break;
}
case MPV_EVENT_FILE_LOADED: {
audio_recovery_.SetFileLoaded(true);
SendEvent("file-loaded");
break;
}
@@ -650,11 +675,11 @@ FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) {
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
if (!name) return;
auto it = name_to_id_.find(name);
if (it == name_to_id_.end()) return;
int id = 0;
if (!observed_properties_.LookupId(name, &id)) return;
FlValue* list = fl_value_new_list();
fl_value_append_take(list, fl_value_new_int(it->second));
fl_value_append_take(list, fl_value_new_int(id));
if (data) {
fl_value_append_take(list, NodeToFlValue(data));
} else {
@@ -683,4 +708,12 @@ void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
fl_value_unref(event_map);
}
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
hdr_enabled_ = enabled;
if (!mpv_) {
if (callback) callback(0);
return;
}
SetPropertyAsync("target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled), std::move(callback));
}
} // namespace mpv
+15 -18
View File
@@ -10,7 +10,6 @@
#include <atomic>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <string>
@@ -18,6 +17,8 @@
#include <tuple>
#include <vector>
#include "../../../native/mpv/mpv_player_common.h"
// Forward declaration for Flutter types
struct _FlValue;
@@ -78,9 +79,9 @@ class MpvPlayer {
void Command(const std::vector<std::string>& args);
/// Callback types for async mpv requests.
using StatusCallback = std::function<void(int error)>;
using StatusCallback = plezy::mpv_common::StatusCallback;
using CommandCallback = StatusCallback;
using GetPropertyCallback = std::function<void(int error, const std::string& value)>;
using GetPropertyCallback = plezy::mpv_common::GetPropertyCallback;
/// Executes an mpv command asynchronously to prevent UI blocking.
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
@@ -136,11 +137,11 @@ class MpvPlayer {
/// Sends an event notification.
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
uint64_t RegisterStatusRequest(StatusCallback callback);
StatusCallback TakeStatusRequest(uint64_t request_id);
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
void MaybeRunAudioRecovery();
void TryAudioReload(const char* reason, int attempt);
void EnsureAudioRecoveryTimer();
void LogRecovery(const std::string& text);
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
/// Helper to convert mpv_node to FlValue.
::_FlValue* NodeToFlValue(mpv_node* node);
@@ -158,18 +159,14 @@ class MpvPlayer {
EventCallback event_callback_;
RedrawCallback redraw_callback_;
std::mutex callback_mutex_;
plezy::mpv_common::AudioRecoveryState audio_recovery_;
plezy::mpv_common::AsyncRequestRegistry pending_requests_;
plezy::mpv_common::PropertyObservationRegistry observed_properties_;
bool hdr_enabled_ = true;
uint64_t next_reply_userdata_ = 1;
std::map<std::string, uint64_t> observed_properties_;
std::map<std::string, int> name_to_id_;
// Pending async requests: request_id -> callback
std::map<uint64_t, StatusCallback> pending_status_requests_;
std::map<uint64_t, GetPropertyCallback> pending_get_property_requests_;
std::mutex pending_requests_mutex_;
// GSource for processing events on main thread
// GLib sources for event delivery and scheduled audio recovery.
guint event_source_id_ = 0;
guint recovery_source_id_ = 0;
};
} // namespace mpv
+250
View File
@@ -0,0 +1,250 @@
#ifndef PLEZY_NATIVE_MPV_PLAYER_COMMON_H_
#define PLEZY_NATIVE_MPV_PLAYER_COMMON_H_
#include <mpv/client.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
namespace plezy {
namespace mpv_common {
using StatusCallback = std::function<void(int error)>;
using GetPropertyCallback = std::function<void(int error, const std::string& value)>;
struct CancelledRequests {
std::vector<StatusCallback> status;
std::vector<GetPropertyCallback> properties;
};
class AsyncRequestRegistry {
public:
uint64_t RegisterStatus(StatusCallback callback) {
std::lock_guard<std::mutex> lock(mutex_);
const uint64_t request_id = next_id_++;
status_[request_id] = std::move(callback);
return request_id;
}
StatusCallback TakeStatus(uint64_t request_id) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = status_.find(request_id);
if (it == status_.end()) return nullptr;
auto callback = std::move(it->second);
status_.erase(it);
return callback;
}
uint64_t RegisterProperty(GetPropertyCallback callback) {
std::lock_guard<std::mutex> lock(mutex_);
const uint64_t request_id = next_id_++;
properties_[request_id] = std::move(callback);
return request_id;
}
GetPropertyCallback TakeProperty(uint64_t request_id) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = properties_.find(request_id);
if (it == properties_.end()) return nullptr;
auto callback = std::move(it->second);
properties_.erase(it);
return callback;
}
CancelledRequests CancelAll() {
CancelledRequests cancelled;
std::lock_guard<std::mutex> lock(mutex_);
cancelled.status.reserve(status_.size());
for (auto& request : status_) {
if (request.second) {
cancelled.status.push_back(std::move(request.second));
}
}
cancelled.properties.reserve(properties_.size());
for (auto& request : properties_) {
if (request.second) {
cancelled.properties.push_back(std::move(request.second));
}
}
status_.clear();
properties_.clear();
return cancelled;
}
private:
uint64_t next_id_ = 1;
std::map<uint64_t, StatusCallback> status_;
std::map<uint64_t, GetPropertyCallback> properties_;
std::mutex mutex_;
};
inline mpv_format ParsePropertyFormat(const std::string& format) {
if (format == "string") return MPV_FORMAT_STRING;
if (format == "flag" || format == "bool") return MPV_FORMAT_FLAG;
if (format == "int64") return MPV_FORMAT_INT64;
if (format == "double") return MPV_FORMAT_DOUBLE;
if (format == "node") return MPV_FORMAT_NODE;
return MPV_FORMAT_NONE;
}
struct ObservationRequest {
bool added;
uint64_t userdata;
mpv_format format;
};
class PropertyObservationRegistry {
public:
ObservationRequest Register(const std::string& name, const std::string& format, int id) {
if (userdata_by_name_.find(name) != userdata_by_name_.end()) {
return {false, 0, MPV_FORMAT_NONE};
}
const uint64_t userdata = next_userdata_++;
userdata_by_name_[name] = userdata;
id_by_name_[name] = id;
return {true, userdata, ParsePropertyFormat(format)};
}
bool LookupId(const std::string& name, int* id) const {
const auto it = id_by_name_.find(name);
if (it == id_by_name_.end()) return false;
*id = it->second;
return true;
}
void Clear() {
userdata_by_name_.clear();
id_by_name_.clear();
}
private:
uint64_t next_userdata_ = 1;
std::map<std::string, uint64_t> userdata_by_name_;
std::map<std::string, int> id_by_name_;
};
inline bool ParseEnabledFlag(const std::string& value) { return value == "yes" || value == "true" || value == "1"; }
inline const char* TargetColorspaceHint(bool hdr_enabled) { return hdr_enabled ? "auto" : "no"; }
enum class AudioReloadReason { kNone, kResume, kNullFallback };
struct AudioReloadAction {
AudioReloadReason reason = AudioReloadReason::kNone;
int attempt = 0;
bool exhausted = false;
};
enum class AudioOutputTransition { kNone, kFellBackToNull, kRecovered };
class AudioRecoveryState {
public:
using Clock = std::chrono::steady_clock;
void SetFileLoaded(bool loaded) {
file_loaded_ = loaded;
if (!loaded) {
resume_attempts_left_ = 0;
null_attempts_left_ = 0;
}
}
void RequestResume() { resume_requested_.store(true); }
AudioOutputTransition SetCurrentAudioOutputNull(bool is_null, Clock::time_point now) {
if (is_null == current_ao_is_null_) return AudioOutputTransition::kNone;
current_ao_is_null_ = is_null;
if (is_null) {
null_attempts_left_ = kNullRetryBudget;
null_backoff_ = NullFirstDelay();
null_next_attempt_ = now + NullFirstDelay();
return AudioOutputTransition::kFellBackToNull;
}
null_attempts_left_ = 0;
return AudioOutputTransition::kRecovered;
}
bool OnAudioDeviceListChanged(Clock::time_point now) {
if (!current_ao_is_null_) return false;
const auto candidate = now + DeviceListDebounce();
if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) {
null_next_attempt_ = candidate;
}
null_attempts_left_ = kNullRetryBudget;
null_backoff_ = NullFirstDelay();
return true;
}
AudioReloadAction NextReload(Clock::time_point now) {
if (resume_requested_.exchange(false) && file_loaded_) {
resume_attempts_left_ = kResumeReloadAttempts;
resume_next_attempt_ = now + ResumeFirstDelay();
}
if (reload_pending_) return {};
if (resume_attempts_left_ > 0 && now >= resume_next_attempt_) {
const int attempt = kResumeReloadAttempts - resume_attempts_left_ + 1;
--resume_attempts_left_;
resume_next_attempt_ = now + ResumeRetryDelay();
reload_pending_ = true;
return {AudioReloadReason::kResume, attempt, false};
}
if (null_attempts_left_ > 0 && now >= null_next_attempt_) {
if (!current_ao_is_null_) {
null_attempts_left_ = 0;
return {};
}
const int attempt = kNullRetryBudget - null_attempts_left_ + 1;
--null_attempts_left_;
null_next_attempt_ = now + null_backoff_;
null_backoff_ = std::min(null_backoff_ * 2, NullBackoffCap());
reload_pending_ = true;
return {AudioReloadReason::kNullFallback, attempt, null_attempts_left_ == 0};
}
return {};
}
void CompleteReload() { reload_pending_ = false; }
bool HasPendingWork() const {
return resume_requested_.load() || resume_attempts_left_ > 0 || null_attempts_left_ > 0 || reload_pending_;
}
bool current_audio_output_is_null() const { return current_ao_is_null_; }
static int NullRetryBudget() { return kNullRetryBudget; }
private:
static constexpr int kResumeReloadAttempts = 2;
static constexpr int kNullRetryBudget = 5;
static std::chrono::milliseconds ResumeFirstDelay() { return std::chrono::milliseconds(1500); }
static std::chrono::milliseconds ResumeRetryDelay() { return std::chrono::milliseconds(4500); }
static std::chrono::milliseconds NullFirstDelay() { return std::chrono::milliseconds(500); }
static std::chrono::milliseconds NullBackoffCap() { return std::chrono::milliseconds(8000); }
static std::chrono::milliseconds DeviceListDebounce() { return std::chrono::milliseconds(250); }
std::atomic<bool> resume_requested_{false};
bool file_loaded_ = false;
bool current_ao_is_null_ = false;
bool reload_pending_ = false;
int resume_attempts_left_ = 0;
Clock::time_point resume_next_attempt_{};
int null_attempts_left_ = 0;
Clock::time_point null_next_attempt_{};
std::chrono::milliseconds null_backoff_{0};
};
} // namespace mpv_common
} // namespace plezy
#endif // PLEZY_NATIVE_MPV_PLAYER_COMMON_H_
+146
View File
@@ -0,0 +1,146 @@
#include "mpv_player_common.h"
#include <cassert>
#include <chrono>
#include <string>
namespace {
using plezy::mpv_common::AudioOutputTransition;
using plezy::mpv_common::AudioRecoveryState;
using plezy::mpv_common::AudioReloadReason;
void TestRequestRegistry() {
plezy::mpv_common::AsyncRequestRegistry registry;
bool status_called = false;
bool property_called = false;
const auto status_id = registry.RegisterStatus([&](int error) { status_called = error == -7; });
const auto property_id = registry.RegisterProperty(
[&](int error, const std::string& value) { property_called = error == -8 && value == "value"; });
auto status = registry.TakeStatus(status_id);
auto property = registry.TakeProperty(property_id);
assert(status);
assert(property);
status(-7);
property(-8, "value");
assert(status_called);
assert(property_called);
assert(!registry.TakeStatus(status_id));
assert(!registry.TakeProperty(property_id));
registry.RegisterStatus([](int) {});
registry.RegisterProperty([](int, const std::string&) {});
auto cancelled = registry.CancelAll();
assert(cancelled.status.size() == 1);
assert(cancelled.properties.size() == 1);
}
void TestPropertyObservationRegistry() {
plezy::mpv_common::PropertyObservationRegistry registry;
const auto first = registry.Register("pause", "bool", 17);
const auto duplicate = registry.Register("pause", "string", 99);
const auto node = registry.Register("track-list", "node", 18);
assert(first.added);
assert(first.format == MPV_FORMAT_FLAG);
assert(!duplicate.added);
assert(node.added);
assert(node.format == MPV_FORMAT_NODE);
int id = 0;
assert(registry.LookupId("pause", &id));
assert(id == 17);
assert(!registry.LookupId("missing", &id));
registry.Clear();
assert(!registry.LookupId("pause", &id));
}
void TestResumeRecoverySchedule() {
AudioRecoveryState state;
const auto start = AudioRecoveryState::Clock::time_point{};
state.SetFileLoaded(true);
state.RequestResume();
assert(state.NextReload(start).reason == AudioReloadReason::kNone);
assert(state.HasPendingWork());
assert(state.NextReload(start + std::chrono::milliseconds(1499)).reason == AudioReloadReason::kNone);
const auto first = state.NextReload(start + std::chrono::milliseconds(1500));
assert(first.reason == AudioReloadReason::kResume);
assert(first.attempt == 1);
assert(!first.exhausted);
state.CompleteReload();
const auto second = state.NextReload(start + std::chrono::milliseconds(6000));
assert(second.reason == AudioReloadReason::kResume);
assert(second.attempt == 2);
state.CompleteReload();
assert(!state.HasPendingWork());
}
void TestNullFallbackRecoverySchedule() {
AudioRecoveryState state;
const auto start = AudioRecoveryState::Clock::time_point{};
state.SetFileLoaded(true);
assert(state.SetCurrentAudioOutputNull(true, start) == AudioOutputTransition::kFellBackToNull);
auto action = state.NextReload(start + std::chrono::milliseconds(500));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 1);
state.CompleteReload();
action = state.NextReload(start + std::chrono::milliseconds(1000));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 2);
state.CompleteReload();
action = state.NextReload(start + std::chrono::milliseconds(2000));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 3);
state.CompleteReload();
action = state.NextReload(start + std::chrono::milliseconds(4000));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 4);
state.CompleteReload();
action = state.NextReload(start + std::chrono::milliseconds(8000));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 5);
assert(action.exhausted);
state.CompleteReload();
assert(!state.HasPendingWork());
assert(state.OnAudioDeviceListChanged(start + std::chrono::milliseconds(9000)));
action = state.NextReload(start + std::chrono::milliseconds(9250));
assert(action.reason == AudioReloadReason::kNullFallback);
assert(action.attempt == 1);
state.CompleteReload();
assert(
state.SetCurrentAudioOutputNull(false, start + std::chrono::milliseconds(9300)) ==
AudioOutputTransition::kRecovered);
assert(!state.HasPendingWork());
}
void TestHdrHelpers() {
assert(plezy::mpv_common::ParseEnabledFlag("yes"));
assert(plezy::mpv_common::ParseEnabledFlag("true"));
assert(plezy::mpv_common::ParseEnabledFlag("1"));
assert(!plezy::mpv_common::ParseEnabledFlag("no"));
assert(std::string(plezy::mpv_common::TargetColorspaceHint(true)) == "auto");
assert(std::string(plezy::mpv_common::TargetColorspaceHint(false)) == "no");
}
} // namespace
int main() {
TestRequestRegistry();
TestPropertyObservationRegistry();
TestResumeRecoverySchedule();
TestNullFallbackRecoverySchedule();
TestHdrHelpers();
return 0;
}
@@ -114,4 +114,32 @@ void main() {
expect(activities.last.progress, 75);
expect(activities.last.cancellable, isTrue);
});
test('metadata edit preserves locked fields and removed tag wire format', () async {
http.Request? captured;
final client = makeClient((request) async {
captured = request;
return http.Response('', 200);
});
addTearDown(client.close);
final updated = await client.updateMetadata(
sectionId: 1,
ratingKey: 'item-id',
typeNumber: 1,
title: 'Renamed',
tagChanges: {
'genre': (current: ['Drama'], original: ['Drama', 'Science Fiction']),
},
);
expect(updated, isTrue);
expect(captured?.method, 'PUT');
expect(captured?.url.path, '/library/sections/1/all');
expect(captured?.url.queryParameters, containsPair('title.value', 'Renamed'));
expect(captured?.url.queryParameters, containsPair('title.locked', '1'));
expect(captured?.url.queryParameters, containsPair('genre[0].tag.tag', 'Drama'));
expect(captured?.url.queryParameters, containsPair('genre[].tag.tag-', 'Science%20Fiction'));
expect(captured?.url.queryParameters, containsPair('genre.locked', '1'));
});
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
void main() {
Widget host({
required ViewMode viewMode,
required List<MediaCardSliverPosition> positions,
Object? listEpoch,
Object? gridEpoch,
}) {
return MaterialApp(
home: CustomScrollView(
slivers: [
MediaCardSliverLayout(
viewMode: viewMode,
itemCount: 6,
density: 100,
padding: EdgeInsets.zero,
listEpoch: listEpoch,
gridEpochBuilder: gridEpoch == null ? null : (_) => gridEpoch,
itemBuilder: (context, position) {
positions.add(position);
return SizedBox(key: ValueKey(position.index), height: 40, child: Text('${position.index}'));
},
),
],
),
);
}
testWidgets('list mode exposes one-column card positions', (tester) async {
final positions = <MediaCardSliverPosition>[];
final epoch = Object();
await tester.pumpWidget(host(viewMode: ViewMode.list, positions: positions, listEpoch: epoch));
expect(find.byType(SliverList), findsOneWidget);
expect(find.byType(SliverGrid), findsNothing);
expect(positions, isNotEmpty);
expect(positions.first.columnCount, 1);
expect(positions.first.isFirstRow, isTrue);
expect(positions.first.isFirstColumn, isTrue);
expect(positions.first.disableScale, isTrue);
expect(positions.first.layoutEpoch, same(epoch));
});
testWidgets('grid mode exposes geometry-derived card positions', (tester) async {
final positions = <MediaCardSliverPosition>[];
final epoch = Object();
await tester.pumpWidget(host(viewMode: ViewMode.grid, positions: positions, gridEpoch: epoch));
expect(find.byType(SliverGrid), findsOneWidget);
expect(find.byType(SliverList), findsNothing);
expect(positions, isNotEmpty);
final first = positions.first;
expect(first.columnCount, greaterThan(1));
expect(first.isGrid, isTrue);
expect(first.isFirstRow, isTrue);
expect(first.isFirstColumn, isTrue);
expect(first.disableScale, isFalse);
expect(first.layoutEpoch, same(epoch));
});
}
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/widgets/tv_spotlight_scaffold.dart';
MediaItem _item(String id) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: id);
MediaHub _hub(String id, List<MediaItem> items) =>
MediaHub(id: id, title: id, type: 'movie', size: items.length, items: items);
void main() {
test('spotlight controller cancels an intermediate debounced selection', () async {
final first = _item('first');
final second = _item('second');
final controller = TvSpotlightController(settleDelay: const Duration(milliseconds: 10));
addTearDown(controller.dispose);
controller.value = first;
controller.select(second);
controller.select(first);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(controller.value, same(first));
});
test('spotlight controller resolves valid selection or first hub item', () {
final first = _item('first');
final second = _item('second');
final controller = TvSpotlightController(settleDelay: Duration.zero);
addTearDown(controller.dispose);
final hubs = [
_hub('one', [first]),
_hub('two', [second]),
];
expect(controller.resolve(hubs), same(first));
controller.select(second);
expect(controller.resolve(hubs), same(second));
controller.value = _item('removed');
expect(controller.resolve(hubs), same(first));
});
}
+47 -196
View File
@@ -2,29 +2,12 @@
#include <windowsx.h>
#include <algorithm>
#include "sanitize_utf8.h"
namespace mpv {
namespace {
// Audio recovery schedule (issue #783: silent WASAPI after wake from sleep).
// Resume reloads fire unconditionally — a post-wake WASAPI session can stay
// "healthy" from mpv's point of view while producing no sound, so there is no
// property to gate on; the second shot covers a first reload that lands while
// the audio stack is still restoring and creates another silent session.
// Null-fallback retries are clock-driven because a failed ao-reload falls
// back to null again without emitting a current-ao change event.
constexpr int kResumeReloadAttempts = 2;
constexpr std::chrono::milliseconds kResumeFirstDelay{1500};
constexpr std::chrono::milliseconds kResumeRetryDelay{4500};
constexpr int kNullRetryBudget = 5;
constexpr std::chrono::milliseconds kNullFirstDelay{500};
constexpr std::chrono::milliseconds kNullBackoffCap{8000};
constexpr std::chrono::milliseconds kDeviceListDebounce{250};
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
if (!node) return flutter::EncodableValue();
@@ -176,7 +159,7 @@ bool MpvPlayer::Initialize(HWND view) {
if (!audio_only_) {
// Let mpv use display/context detection instead of forcing HDR signaling.
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto");
mpv_set_option_string(mpv_, "target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(hdr_enabled_));
// Fallback tone mapping when display doesn't support HDR
mpv_set_option_string(mpv_, "tone-mapping", "auto");
@@ -203,10 +186,6 @@ bool MpvPlayer::Initialize(HWND view) {
return false;
}
// Observe video-params/sig-peak for HDR detection (video core only).
if (!audio_only_) {
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
}
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
// Native observation so audio recovery doesn't depend on the Dart side
// choosing to observe the device list.
@@ -221,24 +200,11 @@ bool MpvPlayer::Initialize(HWND view) {
void MpvPlayer::Dispose() {
StopEventLoop();
// Cancel pending async requests
std::vector<StatusCallback> status_callbacks;
std::vector<GetPropertyCallback> get_callbacks;
{
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
for (auto& pair : pending_status_requests_) {
if (pair.second) status_callbacks.push_back(std::move(pair.second));
}
for (auto& pair : pending_get_property_requests_) {
if (pair.second) get_callbacks.push_back(std::move(pair.second));
}
pending_status_requests_.clear();
pending_get_property_requests_.clear();
}
for (auto& callback : status_callbacks) {
auto cancelled = pending_requests_.CancelAll();
for (auto& callback : cancelled.status) {
callback(-1);
}
for (auto& callback : get_callbacks) {
for (auto& callback : cancelled.properties) {
callback(-1, "");
}
@@ -265,7 +231,7 @@ void MpvPlayer::Dispose() {
std::thread([handle]() { mpv_terminate_destroy(handle); }).detach();
}
observed_properties_.clear();
observed_properties_.Clear();
}
void MpvPlayer::Command(const std::vector<std::string>& args) { CommandAsync(args, nullptr); }
@@ -283,12 +249,12 @@ void MpvPlayer::CommandAsync(const std::vector<std::string>& args, CommandCallba
}
c_args.push_back(nullptr);
uint64_t request_id = callback ? RegisterStatusRequest(std::move(callback)) : 0;
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
// mpv_command_async returns immediately
int result = mpv_command_async(mpv_, request_id, c_args.data());
if (result < 0) {
auto cb = TakeStatusRequest(request_id);
auto cb = pending_requests_.TakeStatus(request_id);
if (cb) cb(result);
}
}
@@ -305,17 +271,16 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
// Handle custom HDR toggle property (same pattern as iOS/macOS)
if (name == "hdr-enabled") {
bool enabled = (value == "yes" || value == "true" || value == "1");
SetHDREnabled(enabled, std::move(callback));
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
return;
}
uint64_t request_id = callback ? RegisterStatusRequest(std::move(callback)) : 0;
uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0;
char* property_value = const_cast<char*>(value.c_str());
int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value);
if (result < 0) {
auto cb = TakeStatusRequest(request_id);
auto cb = pending_requests_.TakeStatus(request_id);
if (cb) cb(result);
}
}
@@ -326,73 +291,21 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca
return;
}
uint64_t request_id = RegisterGetPropertyRequest(std::move(callback));
uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback));
int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING);
if (result < 0) {
auto cb = TakeGetPropertyRequest(request_id);
auto cb = pending_requests_.TakeProperty(request_id);
if (cb) cb(result, "");
}
}
uint64_t MpvPlayer::RegisterStatusRequest(StatusCallback callback) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
uint64_t request_id = next_reply_userdata_++;
pending_status_requests_[request_id] = std::move(callback);
return request_id;
}
MpvPlayer::StatusCallback MpvPlayer::TakeStatusRequest(uint64_t request_id) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
auto it = pending_status_requests_.find(request_id);
if (it == pending_status_requests_.end()) return nullptr;
auto callback = std::move(it->second);
pending_status_requests_.erase(it);
return callback;
}
uint64_t MpvPlayer::RegisterGetPropertyRequest(GetPropertyCallback callback) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
uint64_t request_id = next_reply_userdata_++;
pending_get_property_requests_[request_id] = std::move(callback);
return request_id;
}
MpvPlayer::GetPropertyCallback MpvPlayer::TakeGetPropertyRequest(uint64_t request_id) {
std::lock_guard<std::mutex> lock(pending_requests_mutex_);
auto it = pending_get_property_requests_.find(request_id);
if (it == pending_get_property_requests_.end()) return nullptr;
auto callback = std::move(it->second);
pending_get_property_requests_.erase(it);
return callback;
}
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
if (!mpv_) return;
// Check if already observing.
if (observed_properties_.find(name) != observed_properties_.end()) {
return;
}
name_to_id_[name] = id;
mpv_format mpv_fmt = MPV_FORMAT_NONE;
if (format == "string") {
mpv_fmt = MPV_FORMAT_STRING;
} else if (format == "flag" || format == "bool") {
mpv_fmt = MPV_FORMAT_FLAG;
} else if (format == "int64") {
mpv_fmt = MPV_FORMAT_INT64;
} else if (format == "double") {
mpv_fmt = MPV_FORMAT_DOUBLE;
} else if (format == "node") {
mpv_fmt = MPV_FORMAT_NODE;
}
uint64_t userdata = next_reply_userdata_++;
observed_properties_[name] = userdata;
mpv_observe_property(mpv_, userdata, name.c_str(), mpv_fmt);
const auto request = observed_properties_.Register(name, format, id);
if (!request.added) return;
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
}
void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) {
@@ -429,7 +342,7 @@ void MpvPlayer::SetEventCallback(EventCallback callback) {
void MpvPlayer::NotifyPowerSuspend() { LogRecovery("system suspending"); }
void MpvPlayer::NotifyPowerResume() { resume_reload_requested_.store(true); }
void MpvPlayer::NotifyPowerResume() { audio_recovery_.RequestResume(); }
void MpvPlayer::LogRecovery(const std::string& text) {
char log_msg[512];
@@ -446,52 +359,25 @@ void MpvPlayer::LogRecovery(const std::string& text) {
}
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
if (audio_reload_pending_) return;
audio_reload_pending_ = true;
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
std::string reason_str = reason;
CommandAsync({"ao-reload"}, [this, reason_str, attempt](int error) {
audio_reload_pending_ = false;
const std::string reason_copy = reason;
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
audio_recovery_.CompleteReload();
LogRecovery(
"ao-reload completed (reason=" + reason_str + ", attempt " + std::to_string(attempt) +
"ao-reload completed (reason=" + reason_copy + ", attempt " + std::to_string(attempt) +
", error=" + std::to_string(error) + ")");
});
}
void MpvPlayer::MaybeRunAudioRecovery() {
const auto now = std::chrono::steady_clock::now();
if (resume_reload_requested_.exchange(false)) {
if (file_loaded_) {
resume_attempts_left_ = kResumeReloadAttempts;
resume_next_attempt_ = now + kResumeFirstDelay;
LogRecovery("power resume detected; scheduling ao-reload in " + std::to_string(kResumeFirstDelay.count()) + "ms");
} else {
LogRecovery("power resume detected; no file loaded, nothing to recover");
}
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
return;
}
if (resume_attempts_left_ > 0 && now >= resume_next_attempt_) {
int attempt = kResumeReloadAttempts - resume_attempts_left_ + 1;
resume_attempts_left_--;
resume_next_attempt_ = now + kResumeRetryDelay;
TryAudioReload("resume", attempt);
}
if (null_attempts_left_ > 0 && now >= null_next_attempt_) {
if (!current_ao_is_null_) {
null_attempts_left_ = 0;
LogRecovery("audio recovered (current-ao no longer null)");
} else {
int attempt = kNullRetryBudget - null_attempts_left_ + 1;
null_attempts_left_--;
null_next_attempt_ = now + null_backoff_;
null_backoff_ = std::min(null_backoff_ * 2, kNullBackoffCap);
TryAudioReload("null-fallback", attempt);
if (null_attempts_left_ == 0) {
LogRecovery("audio recovery budget exhausted; waiting for device list change or power resume");
}
}
const char* reason = action.reason == plezy::mpv_common::AudioReloadReason::kResume ? "resume" : "null-fallback";
TryAudioReload(reason, action.attempt);
if (action.exhausted) {
LogRecovery("audio recovery budget exhausted; waiting for device list change");
}
}
@@ -531,7 +417,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
case MPV_EVENT_COMMAND_REPLY:
case MPV_EVENT_SET_PROPERTY_REPLY: {
uint64_t request_id = event->reply_userdata;
StatusCallback callback = TakeStatusRequest(request_id);
StatusCallback callback = pending_requests_.TakeStatus(request_id);
if (callback) {
callback(event->error);
}
@@ -539,7 +425,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
}
case MPV_EVENT_GET_PROPERTY_REPLY: {
uint64_t request_id = event->reply_userdata;
GetPropertyCallback callback = TakeGetPropertyRequest(request_id);
GetPropertyCallback callback = pending_requests_.TakeProperty(request_id);
if (callback) {
std::string value;
if (event->error >= 0) {
@@ -594,44 +480,22 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
break;
}
// Handle sig-peak for HDR detection
if (strcmp(prop->name, "video-params/sig-peak") == 0 && prop->format == MPV_FORMAT_DOUBLE && prop->data) {
double sigPeak = *static_cast<double*>(prop->data);
last_sig_peak_ = sigPeak;
UpdateHDRMode(sigPeak);
}
if (strcmp(prop->name, "current-ao") == 0) {
const char* current_ao = nullptr;
if (prop->format == MPV_FORMAT_STRING && prop->data) {
current_ao = *static_cast<char**>(prop->data);
}
bool is_null = current_ao && strcmp(current_ao, "null") == 0;
if (is_null && !current_ao_is_null_) {
// AO fell back to null (audio-fallback-to-null); start recovery.
null_attempts_left_ = kNullRetryBudget;
null_backoff_ = kNullFirstDelay;
null_next_attempt_ = std::chrono::steady_clock::now() + kNullFirstDelay;
LogRecovery(
"current-ao fell back to null; starting recovery (budget " + std::to_string(kNullRetryBudget) + ")");
} else if (!is_null && current_ao_is_null_) {
null_attempts_left_ = 0;
LogRecovery(std::string("current-ao is now '") + (current_ao ? current_ao : "") + "'");
const bool is_null = current_ao && strcmp(current_ao, "null") == 0;
const auto transition =
audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now());
if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) {
LogRecovery("current-ao fell back to null; starting recovery");
} else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) {
LogRecovery("audio recovered (current-ao no longer null)");
}
current_ao_is_null_ = is_null;
}
// A device (re)appearing while the AO sits on the null fallback is a
// fresh recovery opportunity: refresh the retry budget and pull the next
// attempt close. Gated on the native observation (userdata 0) so the
// Dart-side observation of the same property doesn't double-trigger.
if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && current_ao_is_null_) {
auto candidate = std::chrono::steady_clock::now() + kDeviceListDebounce;
if (null_attempts_left_ <= 0 || candidate < null_next_attempt_) {
null_next_attempt_ = candidate;
}
null_attempts_left_ = kNullRetryBudget;
null_backoff_ = kNullFirstDelay;
if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 &&
audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) {
LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload");
}
@@ -639,9 +503,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
break;
}
case MPV_EVENT_END_FILE: {
file_loaded_ = false;
resume_attempts_left_ = 0;
null_attempts_left_ = 0;
audio_recovery_.SetFileLoaded(false);
auto* end = static_cast<mpv_event_end_file*>(event->data);
flutter::EncodableMap data;
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
@@ -653,7 +515,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
break;
}
case MPV_EVENT_FILE_LOADED: {
file_loaded_ = true;
audio_recovery_.SetFileLoaded(true);
SendEvent("file-loaded");
break;
}
@@ -673,13 +535,13 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
if (!name) return;
auto it = name_to_id_.find(name);
if (it == name_to_id_.end()) return;
int id = 0;
if (!observed_properties_.LookupId(name, &id)) return;
// mpv owns event node storage; copy the full tree before the callback can
// queue it beyond the current mpv_wait_event result's lifetime.
flutter::EncodableList list;
list.push_back(flutter::EncodableValue(it->second));
list.push_back(flutter::EncodableValue(id));
list.push_back(NodeToEncodableValue(data));
std::lock_guard<std::mutex> lock(callback_mutex_);
@@ -704,22 +566,11 @@ void MpvPlayer::SendEvent(const std::string& name, const flutter::EncodableMap&
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
hdr_enabled_ = enabled;
if (mpv_) {
SetPropertyAsync("target-colorspace-hint", enabled ? "auto" : "no", std::move(callback));
} else if (callback) {
callback(0);
if (!mpv_) {
if (callback) callback(0);
return;
}
UpdateHDRMode(last_sig_peak_);
}
void MpvPlayer::UpdateHDRMode(double sigPeak) {
// On Windows, mpv handles HDR passthrough automatically when:
// - target-colorspace-hint=auto
// - Windows HDR is enabled in Display Settings
// - Display supports HDR
// No explicit DXGI calls needed - mpv's gpu-next/vulkan handles it
SetPropertyAsync("target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled), std::move(callback));
}
} // namespace mpv
+8 -31
View File
@@ -8,13 +8,14 @@
#include <atomic>
#include <chrono>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "../../../native/mpv/mpv_player_common.h"
namespace mpv {
// Wrapper for libmpv that handles initialization, commands, properties,
@@ -45,9 +46,9 @@ class MpvPlayer {
void Command(const std::vector<std::string>& args);
// Callback types for async mpv requests.
using StatusCallback = std::function<void(int error)>;
using StatusCallback = plezy::mpv_common::StatusCallback;
using CommandCallback = StatusCallback;
using GetPropertyCallback = std::function<void(int error, const std::string& value)>;
using GetPropertyCallback = plezy::mpv_common::GetPropertyCallback;
// Executes an mpv command asynchronously to prevent UI blocking.
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
@@ -95,10 +96,6 @@ class MpvPlayer {
void MaybeRunAudioRecovery();
void TryAudioReload(const char* reason, int attempt);
void LogRecovery(const std::string& text);
uint64_t RegisterStatusRequest(StatusCallback callback);
StatusCallback TakeStatusRequest(uint64_t request_id);
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
const bool audio_only_;
mpv_handle* mpv_ = nullptr;
@@ -108,35 +105,15 @@ class MpvPlayer {
std::atomic<bool> running_{false};
EventCallback event_callback_;
std::mutex callback_mutex_;
bool current_ao_is_null_ = false;
bool audio_reload_pending_ = false;
plezy::mpv_common::AudioRecoveryState audio_recovery_;
// Audio recovery state. Event thread only, except |resume_reload_requested_|
// which the platform thread sets on WM_POWERBROADCAST resume.
std::atomic<bool> resume_reload_requested_{false};
bool file_loaded_ = false;
int resume_attempts_left_ = 0;
std::chrono::steady_clock::time_point resume_next_attempt_{};
int null_attempts_left_ = 0;
std::chrono::steady_clock::time_point null_next_attempt_{};
std::chrono::milliseconds null_backoff_{};
uint64_t next_reply_userdata_ = 1;
std::map<std::string, uint64_t> observed_properties_;
std::map<std::string, int> name_to_id_;
// Pending async requests: request_id -> callback
std::map<uint64_t, StatusCallback> pending_status_requests_;
std::map<uint64_t, GetPropertyCallback> pending_get_property_requests_;
std::mutex pending_requests_mutex_;
plezy::mpv_common::AsyncRequestRegistry pending_requests_;
plezy::mpv_common::PropertyObservationRegistry observed_properties_;
// HDR state
bool hdr_enabled_ = true; // User preference
double last_sig_peak_ = 0.0; // Last known sig-peak for HDR content detection
bool hdr_enabled_ = true;
// HDR methods
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
void UpdateHDRMode(double sigPeak);
};
} // namespace mpv