From 6506f368433988d22506f2aebd3b6c91c33b6134 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:54:10 +0200 Subject: [PATCH] refactor(app): share client and presentation scaffolds --- lib/screens/discover_screen.dart | 109 +-- lib/screens/explore_screen.dart | 116 +-- .../focusable_detail_screen_mixin.dart | 136 +-- lib/screens/hub_detail_screen.dart | 117 +-- .../libraries/tabs/library_browse_tab.dart | 206 ++--- .../tabs/library_collections_tab.dart | 118 +-- .../libraries/tabs/library_playlists_tab.dart | 118 +-- .../tabs/library_recommended_tab.dart | 158 +--- lib/services/plex_client.dart | 847 +----------------- .../plex_client/parts/collections.dart | 262 ++++++ .../plex_client/parts/metadata_edit.dart | 185 ++++ .../plex_client/parts/play_queues.dart | 150 ++++ lib/services/plex_client/parts/playlists.dart | 240 +++++ lib/widgets/media_card_sliver_layout.dart | 130 +++ lib/widgets/tv_spotlight_scaffold.dart | 144 +++ linux/runner/mpv/mpv_player.cc | 199 ++-- linux/runner/mpv/mpv_player.h | 33 +- native/mpv/mpv_player_common.h | 250 ++++++ native/mpv/mpv_player_common_test.cpp | 146 +++ .../plex_client_http_contract_test.dart | 28 + .../media_card_sliver_layout_test.dart | 66 ++ test/widgets/tv_spotlight_scaffold_test.dart | 45 + windows/runner/mpv/mpv_player.cpp | 243 +---- windows/runner/mpv/mpv_player.h | 39 +- 24 files changed, 2142 insertions(+), 1943 deletions(-) create mode 100644 lib/services/plex_client/parts/collections.dart create mode 100644 lib/services/plex_client/parts/metadata_edit.dart create mode 100644 lib/services/plex_client/parts/play_queues.dart create mode 100644 lib/services/plex_client/parts/playlists.dart create mode 100644 lib/widgets/media_card_sliver_layout.dart create mode 100644 lib/widgets/tv_spotlight_scaffold.dart create mode 100644 native/mpv/mpv_player_common.h create mode 100644 native/mpv/mpv_player_common_test.cpp create mode 100644 test/widgets/media_card_sliver_layout_test.dart create mode 100644 test/widgets/tv_spotlight_scaffold_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index cfecd2a6..84be71d2 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 final ValueNotifier _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 _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 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 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 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 } 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( - 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( diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 241ed0d7..c582147d 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -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 List> _orderedHubKeys = const []; final _actionBarKey = GlobalKey(); - // TV spotlight layout (mirrors LibraryRecommendedTab's rail + backdrop). final _tvBrowseRailKey = GlobalKey(); - final ValueNotifier _spotlightItem = ValueNotifier(null); + final TvSpotlightController _spotlight = TvSpotlightController(); @override void initState() { @@ -82,7 +81,7 @@ class ExploreScreenState extends State @override void dispose() { - _spotlightItem.dispose(); + _spotlight.dispose(); super.dispose(); } @@ -95,19 +94,7 @@ class ExploreScreenState extends State _orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory(); } - void _setSpotlightItem(MediaItem item) { - _spotlightItem.value = item; - } - - MediaItem? _effectiveSpotlightItem(List 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 rowHubs) { final liveIds = {for (final rowHub in rowHubs) rowHub.hub.id}; @@ -303,82 +290,27 @@ class ExploreScreenState extends State Widget _buildTvContent(List 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( - 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, ), ), ); diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index b58acaf4..ff0d7ed6 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -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 on State, 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 on State, 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 on State, 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), ); }, ); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index c11104b2..79c0aa57 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -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 ], 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 _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, + ); + }, ); }, ), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 367f4690..c123a7cc 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -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 _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( diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index c1888067..485a91f4 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -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 (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, - ), - ); - }, - ); - }, - ), + ), + ); + }, ); } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index e6b2be75..1f2cbe4f 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -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 (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, - ), - ); - }, - ); - }, - ), + ), + ); + }, ); } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 2bd1b04f..5347463b 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -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> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); - // ValueNotifier (not setState) so a spotlight swap rebuilds only the - // TvSpotlightBackground subtree, never the rail/rows. - final ValueNotifier _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 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( - 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, ), - ], - ), - ), + ), ); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d18dc006..4f7e9325 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> _getPlaylists({String playlistType = 'video', bool? smart}) async { - try { - final all = []; - 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 items, int totalSize})> _getPlaylistsPage({ - String playlistType = 'video', - bool? smart, - int? start, - int? size, - AbortController? abort, - }) async { - final pageSize = size ?? _defaultListContainerSize; - final queryParams = { - 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 _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 metadata = container['Metadata'] as List; - - if (metadata.isEmpty) { - return null; - } - - return PlexPlaylistDto.fromJson(metadata.first as Map); - } 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 createPlaylist({required String title, required List 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 createPlaylistFromUri({ - required String title, - String? uri, - int? playQueueId, - String type = 'video', - }) async { - final queryParams = {'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 metadata = container['Metadata'] as List; - - if (metadata.isEmpty) { - return null; - } - - final dto = PlexPlaylistDto.fromJson( - metadata.first as Map, - ).copyWith(serverId: serverId, serverName: serverName); - return PlexMappers.mediaPlaylist(dto); - } - - /// Delete a playlist - @override - Future 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 addToPlaylist({required String playlistId, required List 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 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 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 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 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 current, List original})>? tagChanges, - }) async { - final queryParams = {'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> findMatches( - String ratingKey, { - String? title, - String? year, - String? agent, - String? language, - }) async { - final queryParams = {'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( - () => _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)) - .toList(); - }, - 'Failed to search for matches', - ); - } - - /// Apply a chosen match to a media item. - Future applyMatch(String ratingKey, {required String guid, String? name, String? year}) async { - final queryParams = {'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 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>> 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>(); - } - 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 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 uploadArtwork(String ratingKey, String element, List 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 updateMetadataPrefs(String ratingKey, Map 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 _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> _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> _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 deleteCollection(MediaItem collection) async { - final sectionId = collection.libraryId ?? ''; - return deleteCollectionById(sectionId, collection.id); - } - - Future 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 createCollection({ - required String libraryId, - required String title, - required List 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 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 addToCollection({required String collectionId, required List 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 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 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 - : data as Map; - final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; - final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; - final metadata = container['Metadata']; - List? items; - if (metadata is List) { - items = [ - for (final e in metadata) - if (e is Map) - 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 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 = { - '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 getPlayQueue( - int playQueueId, { - String? center, - int window = 50, - int includeBefore = 1, - int includeAfter = 1, - String? librarySectionID, - String? librarySectionTitle, - }) async { - try { - final queryParams = { - '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 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> 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> 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( - items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(), - totalCount: result.totalSize, - offset: start ?? 0, - ); - } - - @override - Future fetchPlaylistMetadata(String id) async { - final p = await _getPlaylistMetadata(id); - return p == null ? null : PlexMappers.mediaPlaylist(p); - } - - @override - Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { - final page = await fetchPlaylistPage(id, start: offset, size: limit); - return page.items; - } - - @override - Future> fetchPlaylistPage( - String playlistId, { - int? start, - int? size, - AbortController? abort, - }) async { - final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort); - return LibraryPage( - items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), - totalCount: result.totalSize, - offset: start ?? 0, - ); - } - - @override - Future> fetchCollections(String libraryId) async { - final raw = await _getLibraryCollections(libraryId); - return raw.map((m) => PlexMappers.mediaItem(m)).toList(); - } - - @override - Future> fetchCollectionsPage( - String libraryId, { - int? start, - int? size, - AbortController? abort, - }) async { - final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort); - return LibraryPage( - items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), - totalCount: result.totalSize, - offset: start ?? 0, - ); - } - - @override - Future> 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( - items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), - totalCount: result.totalSize, - offset: start ?? 0, - ); - } - - @override - Future> fetchPersonMediaPage( - String personId, { - int? start, - int? size, - AbortController? abort, - }) async { - final result = await _getPersonMedia(personId, start: start, size: size, abort: abort); - return LibraryPage( - items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), - totalCount: result.totalSize, - offset: start ?? 0, - ); - } - - @override - Future> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId); - - /// Plex-specific: full person-media listing across pages. - Future> 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> fetchHubContent(String hubKey) async { final raw = await _getHubContent(hubKey); diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart new file mode 100644 index 00000000..0c1c85c8 --- /dev/null +++ b/lib/services/plex_client/parts/collections.dart @@ -0,0 +1,262 @@ +part of '../../plex_client.dart'; + +mixin _PlexCollectionMethods on MediaServerCacheMixin { + FailoverHttpClient get _http; + + Future _getWithFailover( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + Map _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> _fetchAllPages( + Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { + AbortController? abort, + }); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future 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> _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> _fetchAllPersonMediaDto(String personId) { + return _fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort)); + } + + @override + Future> fetchCollections(String libraryId) async { + final raw = await _getLibraryCollections(libraryId); + return raw.map(PlexMappers.mediaItem).toList(); + } + + @override + Future> fetchCollectionsPage( + String libraryId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort); + return LibraryPage( + items: result.items.map(PlexMappers.mediaItem).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + @override + Future> 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( + items: result.items.map(PlexMappers.mediaItem).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + @override + Future> fetchPersonMediaPage( + String personId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getPersonMedia(personId, start: start, size: size, abort: abort); + return LibraryPage( + items: result.items.map(PlexMappers.mediaItem).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + @override + Future> fetchPersonMedia(String personId) { + return fetchAllPersonMediaAsMediaItems(personId); + } + + Future> fetchAllPersonMediaAsMediaItems(String personId) async { + final raw = await _fetchAllPersonMediaDto(personId); + return raw.map(PlexMappers.mediaItem).toList(); + } + + @override + Future deleteCollection(MediaItem collection) { + return deleteCollectionById(collection.libraryId ?? '', collection.id); + } + + Future 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 createCollection({ + required String libraryId, + required String title, + required List 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 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 addToCollection({required String collectionId, required List items}) async { + if (items.isEmpty) return true; + final uri = await buildMetadataUri(items.map((item) => item.id).join(',')); + return addItemsToCollectionByUri(collectionId: collectionId, uri: uri); + } + + Future 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 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; + } +} diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart new file mode 100644 index 00000000..03850500 --- /dev/null +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -0,0 +1,185 @@ +part of '../../plex_client.dart'; + +mixin _PlexMetadataEditMethods on MediaServerCacheMixin { + FailoverHttpClient get _http; + PlexApiCache get _cache; + ServerId get serverId; + + Future _getWithFailover( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future> _wrapListApiCall( + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, + String errorMessage, + ); + + Future 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 current, List original})>? tagChanges, + }) async { + final queryParameters = {'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> findMatches( + String ratingKey, { + String? title, + String? year, + String? agent, + String? language, + }) { + final queryParameters = { + '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( + () => _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)).toList(); + }, + 'Failed to search for matches', + ); + } + + Future applyMatch(String ratingKey, {required String guid, String? name, String? year}) async { + final queryParameters = { + '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 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>> 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>() : const []; + } catch (e) { + appLogger.e('Failed to get available artwork', error: e); + return []; + } + } + + Future 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 uploadArtwork(String ratingKey, String element, List 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 updateMetadataPrefs(String ratingKey, Map 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 _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); + } + } +} diff --git a/lib/services/plex_client/parts/play_queues.dart b/lib/services/plex_client/parts/play_queues.dart new file mode 100644 index 00000000..91de5bf2 --- /dev/null +++ b/lib/services/plex_client/parts/play_queues.dart @@ -0,0 +1,150 @@ +part of '../../plex_client.dart'; + +mixin _PlexPlayQueueMethods on MediaServerCacheMixin { + FailoverHttpClient get _http; + + Future _getWithFailover( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + PlexMetadataDto _createTaggedMetadataWithLibrary( + Map json, { + int? librarySectionID, + String? librarySectionTitle, + }); + + Future buildMetadataUri(String ratingKey); + + PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) { + final container = data is Map && data['MediaContainer'] is Map + ? data['MediaContainer'] as Map + : data as Map; + final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID; + final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle; + final metadata = container['Metadata']; + List? items; + if (metadata is List) { + items = [ + for (final entry in metadata) + if (entry is Map) + 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 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 = { + '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 getPlayQueue( + int playQueueId, { + String? center, + int window = 50, + int includeBefore = 1, + int includeAfter = 1, + String? librarySectionID, + String? librarySectionTitle, + }) async { + try { + final queryParameters = { + '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 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; + } + } +} diff --git a/lib/services/plex_client/parts/playlists.dart b/lib/services/plex_client/parts/playlists.dart new file mode 100644 index 00000000..22e1d525 --- /dev/null +++ b/lib/services/plex_client/parts/playlists.dart @@ -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 _getWithFailover( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + Map _buildPaginationParams(int? start, int? size); + + Future<_LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}); + + ({List items, int totalSize}) _extractPlaylistListResult( + MediaServerResponse response, { + int? start, + int? size, + }); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future 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> _getPlaylists({String playlistType = 'video', bool? smart}) async { + try { + final all = []; + 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 items, int totalSize})> _getPlaylistsPage({ + String playlistType = 'video', + bool? smart, + int? start, + int? size, + AbortController? abort, + }) async { + final pageSize = size ?? _defaultPlaylistContainerSize; + final queryParams = { + 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 _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); + } catch (e) { + appLogger.e('Failed to get playlist metadata: $e'); + return null; + } + } + + @override + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { + final playlists = await _getPlaylists(playlistType: playlistType, smart: smart); + return playlists.map(PlexMappers.mediaPlaylist).toList(); + } + + @override + Future> 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( + items: result.items.map(PlexMappers.mediaPlaylist).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + @override + Future fetchPlaylistMetadata(String id) async { + final playlist = await _getPlaylistMetadata(id); + return playlist == null ? null : PlexMappers.mediaPlaylist(playlist); + } + + @override + Future> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async { + final page = await fetchPlaylistPage(id, start: offset, size: limit); + return page.items; + } + + @override + Future> fetchPlaylistPage( + String playlistId, { + int? start, + int? size, + AbortController? abort, + }) async { + final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort); + return LibraryPage( + items: result.items.map(PlexMappers.mediaItem).toList(), + totalCount: result.totalSize, + offset: start ?? 0, + ); + } + + @override + Future createPlaylist({required String title, required List 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 createPlaylistFromUri({ + required String title, + String? uri, + int? playQueueId, + String type = 'video', + }) async { + final queryParameters = { + '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, + ).copyWith(serverId: serverId, serverName: serverName); + return PlexMappers.mediaPlaylist(dto); + } + + @override + Future deletePlaylist(MediaPlaylist playlist) { + return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist'); + } + + @override + Future addToPlaylist({required String playlistId, required List items}) async { + if (items.isEmpty) return true; + final uri = await buildMetadataUri(items.map((item) => item.id).join(',')); + return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri); + } + + Future 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 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 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', + ); + } +} diff --git a/lib/widgets/media_card_sliver_layout.dart b/lib/widgets/media_card_sliver_layout.dart new file mode 100644 index 00000000..288a1888 --- /dev/null +++ b/lib/widgets/media_card_sliver_layout.dart @@ -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? 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, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/tv_spotlight_scaffold.dart b/lib/widgets/tv_spotlight_scaffold.dart new file mode 100644 index 00000000..8af46ab6 --- /dev/null +++ b/lib/widgets/tv_spotlight_scaffold.dart @@ -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 { + 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 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 hubs; + final ValueListenable 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( + 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, + ], + ), + ), + ); + } +} diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 68216e38..b27ec118 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -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 status_callbacks; - std::vector get_callbacks; - { - std::lock_guard 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& 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(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 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 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 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 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(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(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(event->data); FlValue* data = fl_value_new_map(); fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast(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 diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 7fb386b5..402354d6 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -10,7 +10,6 @@ #include #include -#include #include #include #include @@ -18,6 +17,8 @@ #include #include +#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& args); /// Callback types for async mpv requests. - using StatusCallback = std::function; + using StatusCallback = plezy::mpv_common::StatusCallback; using CommandCallback = StatusCallback; - using GetPropertyCallback = std::function; + using GetPropertyCallback = plezy::mpv_common::GetPropertyCallback; /// Executes an mpv command asynchronously to prevent UI blocking. void CommandAsync(const std::vector& 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 observed_properties_; - std::map name_to_id_; - - // Pending async requests: request_id -> callback - std::map pending_status_requests_; - std::map 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 diff --git a/native/mpv/mpv_player_common.h b/native/mpv/mpv_player_common.h new file mode 100644 index 00000000..fe612a55 --- /dev/null +++ b/native/mpv/mpv_player_common.h @@ -0,0 +1,250 @@ +#ifndef PLEZY_NATIVE_MPV_PLAYER_COMMON_H_ +#define PLEZY_NATIVE_MPV_PLAYER_COMMON_H_ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace plezy { +namespace mpv_common { + +using StatusCallback = std::function; +using GetPropertyCallback = std::function; + +struct CancelledRequests { + std::vector status; + std::vector properties; +}; + +class AsyncRequestRegistry { + public: + uint64_t RegisterStatus(StatusCallback callback) { + std::lock_guard 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 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 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 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 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 status_; + std::map 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 userdata_by_name_; + std::map 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 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_ diff --git a/native/mpv/mpv_player_common_test.cpp b/native/mpv/mpv_player_common_test.cpp new file mode 100644 index 00000000..1c228c8c --- /dev/null +++ b/native/mpv/mpv_player_common_test.cpp @@ -0,0 +1,146 @@ +#include "mpv_player_common.h" + +#include +#include +#include + +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; +} diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index 39b3c6c3..b2f894b6 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -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')); + }); } diff --git a/test/widgets/media_card_sliver_layout_test.dart b/test/widgets/media_card_sliver_layout_test.dart new file mode 100644 index 00000000..0f515154 --- /dev/null +++ b/test/widgets/media_card_sliver_layout_test.dart @@ -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 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 = []; + 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 = []; + 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)); + }); +} diff --git a/test/widgets/tv_spotlight_scaffold_test.dart b/test/widgets/tv_spotlight_scaffold_test.dart new file mode 100644 index 00000000..68594fd0 --- /dev/null +++ b/test/widgets/tv_spotlight_scaffold_test.dart @@ -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 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.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)); + }); +} diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index eadcc540..55316fd6 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -2,29 +2,12 @@ #include -#include - #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 status_callbacks; - std::vector get_callbacks; - { - std::lock_guard 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& args) { CommandAsync(args, nullptr); } @@ -283,12 +249,12 @@ void MpvPlayer::CommandAsync(const std::vector& 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(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 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 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 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 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(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(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(event->data); flutter::EncodableMap data; data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast(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 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 diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 3240ecb6..d0cc5493 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -8,13 +8,14 @@ #include #include #include -#include #include #include #include #include #include +#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& args); // Callback types for async mpv requests. - using StatusCallback = std::function; + using StatusCallback = plezy::mpv_common::StatusCallback; using CommandCallback = StatusCallback; - using GetPropertyCallback = std::function; + using GetPropertyCallback = plezy::mpv_common::GetPropertyCallback; // Executes an mpv command asynchronously to prevent UI blocking. void CommandAsync(const std::vector& 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 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 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 observed_properties_; - std::map name_to_id_; - - // Pending async requests: request_id -> callback - std::map pending_status_requests_; - std::map 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