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