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/settings_builder.dart';
|
||||||
import '../widgets/fitting_title_text.dart';
|
import '../widgets/fitting_title_text.dart';
|
||||||
import '../widgets/tv_browse_rail.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/refreshable.dart';
|
||||||
import '../mixins/tab_visibility_aware.dart';
|
import '../mixins/tab_visibility_aware.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/debouncer.dart';
|
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/formatters.dart';
|
import '../utils/formatters.dart';
|
||||||
import '../utils/media_navigation_helper.dart';
|
import '../utils/media_navigation_helper.dart';
|
||||||
@@ -101,12 +100,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
||||||
bool _isAutoScrollPaused = false;
|
bool _isAutoScrollPaused = false;
|
||||||
bool _heroFocusPausedAutoScroll = false;
|
bool _heroFocusPausedAutoScroll = false;
|
||||||
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
|
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||||
// 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));
|
|
||||||
bool _isTabVisible = true;
|
bool _isTabVisible = true;
|
||||||
|
|
||||||
// Track initial load so we can focus hero when content first appears
|
// 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);
|
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/
|
// Memoized on provider list identity (the provider always replaces _onDeck/
|
||||||
// _hubs with fresh instances on change, never mutates in place) so unrelated
|
// _hubs with fresh instances on change, never mutates in place) so unrelated
|
||||||
// rebuilds hand TvBrowseRail the same hubs list and its didUpdateWidget
|
// rebuilds hand TvBrowseRail the same hubs list and its didUpdateWidget
|
||||||
@@ -210,25 +196,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
return hubs;
|
return hubs;
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem? get _effectiveSpotlightItem {
|
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
||||||
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 _scrollToTop() {
|
void _scrollToTop() {
|
||||||
if (!_scrollController.hasClients) return;
|
if (!_scrollController.hasClients) return;
|
||||||
@@ -473,8 +441,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_indicatorTimer?.cancel();
|
_indicatorTimer?.cancel();
|
||||||
_spotlightDebouncer.dispose();
|
_spotlight.dispose();
|
||||||
_spotlightItem.dispose();
|
|
||||||
_indicatorProgress.dispose();
|
_indicatorProgress.dispose();
|
||||||
_heroController.dispose();
|
_heroController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -1204,75 +1171,23 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTvContent(BuildContext context) {
|
Widget _buildTvContent(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final svc = SettingsService.instance;
|
final svc = SettingsService.instance;
|
||||||
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
|
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
|
||||||
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
|
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
|
||||||
final hubsSpanMultipleServers = _hubsSpanMultipleServers();
|
final hubsSpanMultipleServers = _hubsSpanMultipleServers();
|
||||||
final browseHubs = _tvBrowseHubs;
|
final browseHubs = _tvBrowseHubs;
|
||||||
final 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 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(
|
return TvSpotlightScaffold(
|
||||||
color: theme.scaffoldBackgroundColor,
|
hubs: browseHubs,
|
||||||
child: Stack(
|
spotlightListenable: _spotlight,
|
||||||
|
resolveSpotlight: () => _spotlight.resolve(browseHubs),
|
||||||
|
resolveClient: _getMediaClientForItem,
|
||||||
|
hideSpoilers: hideSpoilers,
|
||||||
|
foreground: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
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 (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Center(
|
Center(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart';
|
|||||||
import '../widgets/hub_section.dart';
|
import '../widgets/hub_section.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../widgets/tv_browse_rail.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 'catalog_search_screen.dart';
|
||||||
import 'libraries/state_messages.dart';
|
import 'libraries/state_messages.dart';
|
||||||
|
|
||||||
@@ -51,9 +51,8 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||||
|
|
||||||
// TV spotlight layout (mirrors LibraryRecommendedTab's rail + backdrop).
|
|
||||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||||
final ValueNotifier<MediaItem?> _spotlightItem = ValueNotifier(null);
|
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -82,7 +81,7 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_spotlightItem.dispose();
|
_spotlight.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,19 +94,7 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
_orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory();
|
_orderedHubKeys.firstOrNull?.currentState?.requestFocusFromMemory();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setSpotlightItem(MediaItem item) {
|
void _setSpotlightItem(MediaItem item) => _spotlight.select(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 _updateHubKeys(List<ExploreRowHub> rowHubs) {
|
void _updateHubKeys(List<ExploreRowHub> rowHubs) {
|
||||||
final liveIds = <String>{for (final rowHub in rowHubs) rowHub.hub.id};
|
final liveIds = <String>{for (final rowHub in rowHubs) rowHub.hub.id};
|
||||||
@@ -303,64 +290,12 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
|
|
||||||
Widget _buildTvContent(List<ExploreRowHub> rowHubs) {
|
Widget _buildTvContent(List<ExploreRowHub> rowHubs) {
|
||||||
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
|
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
|
||||||
final size = MediaQuery.sizeOf(context);
|
return TvSpotlightScaffold(
|
||||||
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,
|
|
||||||
hubs: tvHubs,
|
hubs: tvHubs,
|
||||||
density: svc.read(SettingsService.libraryDensity),
|
spotlightListenable: _spotlight,
|
||||||
episodePosterMode: svc.read(SettingsService.episodePosterMode),
|
resolveSpotlight: () => _spotlight.resolve(tvHubs),
|
||||||
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
|
resolveClient: (spotlight) => context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId)),
|
||||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
foreground: Positioned(
|
||||||
);
|
|
||||||
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,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -378,9 +313,6 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ import '../services/settings_service.dart';
|
|||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../utils/grid_size_calculator.dart';
|
|
||||||
import '../widgets/focusable_media_card.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/skeleton_media_card.dart';
|
||||||
import '../widgets/sliver_cross_axis_layout_builder.dart';
|
|
||||||
|
|
||||||
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
||||||
/// Flutter widget Key.
|
/// Flutter widget Key.
|
||||||
@@ -176,77 +174,38 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
|||||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final svc = SettingsService.instance;
|
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 libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||||
|
|
||||||
if (isListMode) {
|
return MediaCardSliverLayout(
|
||||||
return SliverPadding(
|
viewMode: viewMode,
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverList.builder(
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
itemCount: items.length,
|
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(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
|
||||||
builder: (context, crossAxisExtent) {
|
|
||||||
final geometry = MediaGridGeometry.resolve(
|
|
||||||
context: context,
|
|
||||||
crossAxisExtent: crossAxisExtent,
|
|
||||||
density: libraryDensity,
|
density: libraryDensity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
shape: shape,
|
shape: shape,
|
||||||
);
|
itemBuilder: (context, position) {
|
||||||
return SliverGrid.builder(
|
final index = position.index;
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
gridDelegate: geometry.delegate,
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
final inFirstRow = GridSizeCalculator.isFirstRow(index, geometry.columnCount);
|
|
||||||
final focusNode = _focusNodeForIndex(index);
|
final focusNode = _focusNodeForIndex(index);
|
||||||
|
|
||||||
return FocusableMediaCard(
|
return FocusableMediaCard(
|
||||||
key: Key(_idForItem(item)),
|
key: Key(_idForItem(item)),
|
||||||
item: item,
|
item: item,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
|
disableScale: position.disableScale,
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
collectionId: collectionId,
|
collectionId: collectionId,
|
||||||
onListRefresh: onListRefresh,
|
onListRefresh: onListRefresh,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout && position.isGrid,
|
||||||
onNavigateUp: inFirstRow ? navigateToAppBar : null,
|
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||||
onBack: handleBackFromContent,
|
onBack: handleBackFromContent,
|
||||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
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],
|
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final svc = SettingsService.instance;
|
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 libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||||
|
|
||||||
@@ -292,41 +251,14 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isListMode) {
|
return MediaCardSliverLayout(
|
||||||
return SliverPadding(
|
viewMode: viewMode,
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverList.builder(
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
itemCount: totalItems,
|
itemCount: totalItems,
|
||||||
itemBuilder: (context, index) => buildTile(index, inFirstRow: index == 0, disableScale: true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SliverPadding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
|
||||||
builder: (context, crossAxisExtent) {
|
|
||||||
final geometry = MediaGridGeometry.resolve(
|
|
||||||
context: context,
|
|
||||||
crossAxisExtent: crossAxisExtent,
|
|
||||||
density: libraryDensity,
|
density: libraryDensity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
);
|
itemBuilder: (context, position) =>
|
||||||
return SliverGrid.builder(
|
buildTile(position.index, inFirstRow: position.isFirstRow, disableScale: position.disableScale),
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
gridDelegate: geometry.delegate,
|
|
||||||
itemCount: totalItems,
|
|
||||||
itemBuilder: (context, index) => buildTile(
|
|
||||||
index,
|
|
||||||
inFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
|
|
||||||
disableScale: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import '../services/settings_service.dart';
|
|||||||
import '../widgets/settings_builder.dart';
|
import '../widgets/settings_builder.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/continuation_pagination_coordinator.dart';
|
import '../utils/continuation_pagination_coordinator.dart';
|
||||||
import '../utils/grid_size_calculator.dart';
|
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import '../utils/plex_library_section_utils.dart';
|
import '../utils/plex_library_section_utils.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../widgets/focusable_media_card.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/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/desktop_app_bar.dart';
|
||||||
import '../widgets/loading_indicator_box.dart';
|
import '../widgets/loading_indicator_box.dart';
|
||||||
import '../widgets/overlay_sheet.dart';
|
import '../widgets/overlay_sheet.dart';
|
||||||
@@ -479,7 +477,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
],
|
],
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final svc = SettingsService.instance;
|
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 episodePosterMode = svc.read(SettingsService.episodePosterMode);
|
||||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||||
@@ -503,88 +501,39 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
_filteredItems.isNotEmpty &&
|
_filteredItems.isNotEmpty &&
|
||||||
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
||||||
|
|
||||||
if (isListMode) {
|
return MediaCardSliverLayout(
|
||||||
return SliverPadding(
|
viewMode: viewMode,
|
||||||
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,
|
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(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
|
||||||
builder: (context, crossAxisExtent) {
|
|
||||||
final geometry = MediaGridGeometry.resolve(
|
|
||||||
context: context,
|
|
||||||
crossAxisExtent: crossAxisExtent,
|
|
||||||
density: libraryDensity,
|
density: libraryDensity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
usePaddingAware: true,
|
usePaddingAware: true,
|
||||||
horizontalPadding: 16,
|
horizontalPadding: 16,
|
||||||
useWideAspectRatio: useWideLayout,
|
useWideAspectRatio: useWideLayout,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
shape: isSquareHub ? CardShape.square : null,
|
shape: isSquareHub ? CardShape.square : null,
|
||||||
);
|
itemBuilder: (context, position) {
|
||||||
final columnCount = geometry.columnCount;
|
final index = position.index;
|
||||||
|
|
||||||
return SliverGrid(
|
|
||||||
gridDelegate: geometry.delegate,
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(context, index) {
|
|
||||||
final item = _filteredItems[index];
|
final item = _filteredItems[index];
|
||||||
final focusNode = _focusNodeForIndex(index);
|
final focusNode = _focusNodeForIndex(index);
|
||||||
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
|
|
||||||
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
|
|
||||||
|
|
||||||
return FocusableMediaCard(
|
return FocusableMediaCard(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
item: item,
|
item: item,
|
||||||
|
disableScale: position.disableScale,
|
||||||
onRefresh: _handleItemRefresh,
|
onRefresh: _handleItemRefresh,
|
||||||
onRemoveFromContinueWatching: widget.isInContinueWatching
|
onRemoveFromContinueWatching: widget.isInContinueWatching
|
||||||
? _handleRemoveFromContinueWatching
|
? _handleRemoveFromContinueWatching
|
||||||
: null,
|
: null,
|
||||||
isInContinueWatching: widget.isInContinueWatching,
|
isInContinueWatching: widget.isInContinueWatching,
|
||||||
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
||||||
onNavigateUp: isFirstRow ? navigateToAppBar : null,
|
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||||
onNavigateLeft: isFirstColumn ? () {} : null,
|
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
|
||||||
onBack: handleBackFromContent,
|
onBack: handleBackFromContent,
|
||||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||||
mixedHubContext: isMixedHub,
|
mixedHubContext: isMixedHub,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout && position.isGrid,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _filteredItems.length,
|
|
||||||
addAutomaticKeepAlives: false,
|
|
||||||
addSemanticIndexes: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ import '../../../widgets/focusable_media_card.dart';
|
|||||||
import '../../../widgets/focusable_filter_chip.dart';
|
import '../../../widgets/focusable_filter_chip.dart';
|
||||||
import '../../../widgets/listenable_selector.dart';
|
import '../../../widgets/listenable_selector.dart';
|
||||||
import '../../../widgets/loading_indicator_box.dart';
|
import '../../../widgets/loading_indicator_box.dart';
|
||||||
import '../../../widgets/media_grid_delegate.dart';
|
import '../../../widgets/media_card_sliver_layout.dart';
|
||||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
|
||||||
import '../../../widgets/media_card_list_layout.dart';
|
import '../../../widgets/media_card_list_layout.dart';
|
||||||
import '../../../widgets/bottom_sheet_page_scaffold.dart';
|
import '../../../widgets/bottom_sheet_page_scaffold.dart';
|
||||||
import '../../../widgets/overlay_sheet.dart';
|
import '../../../widgets/overlay_sheet.dart';
|
||||||
@@ -1873,73 +1872,33 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
final browseShape = isMusicGrouping ? CardShape.square : null;
|
final browseShape = isMusicGrouping ? CardShape.square : null;
|
||||||
|
|
||||||
if (viewMode == ViewMode.list) {
|
if (viewMode == ViewMode.list) {
|
||||||
// In list view, all items are in a single column (first column)
|
|
||||||
_setListScrollMetrics(density: libraryDensity, usesWideAspectRatio: useWideRatio, shape: browseShape);
|
_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;
|
final hasAlphaBarReservation = rightPadding > 8.0;
|
||||||
return SliverPadding(
|
return MediaCardSliverLayout(
|
||||||
padding: .fromLTRB(8, topPadding, rightPadding, 8),
|
viewMode: viewMode,
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
itemCount: itemCount,
|
||||||
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,
|
density: libraryDensity,
|
||||||
|
padding: EdgeInsets.fromLTRB(8, topPadding, rightPadding, 8),
|
||||||
useWideAspectRatio: useWideRatio,
|
useWideAspectRatio: useWideRatio,
|
||||||
shape: browseShape,
|
shape: browseShape,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
);
|
crossAxisExtentForColumnCount: hasAlphaBarReservation
|
||||||
final columnCount = geometry.columnCount;
|
? (crossAxisExtent) => crossAxisExtent + (rightPadding - 8.0)
|
||||||
// Cache grid metrics for alpha jump bar scroll calculations
|
: null,
|
||||||
|
onGridGeometry: (geometry) {
|
||||||
_scrollMetrics = LibraryAlphaScrollMetrics(
|
_scrollMetrics = LibraryAlphaScrollMetrics(
|
||||||
columnCount: columnCount,
|
columnCount: geometry.columnCount,
|
||||||
rowHeight: geometry.itemHeight + geometry.spacing,
|
rowHeight: geometry.itemHeight + geometry.spacing,
|
||||||
itemWidth: geometry.itemWidth,
|
itemWidth: geometry.itemWidth,
|
||||||
itemHeight: geometry.itemHeight,
|
itemHeight: geometry.itemHeight,
|
||||||
);
|
);
|
||||||
// Everything the card closures capture; a change flushes the memo
|
},
|
||||||
// so stale nav closures can't misroute d-pad focus.
|
listEpoch: (ViewMode.list, itemCount, libraryDensity, useWideRatio, _shouldShowAlphaJumpBar, isPhone),
|
||||||
final cardEpoch = (
|
gridEpochBuilder: (geometry) => (
|
||||||
ViewMode.grid,
|
ViewMode.grid,
|
||||||
columnCount,
|
geometry.columnCount,
|
||||||
itemCount,
|
itemCount,
|
||||||
fullCardLayout,
|
fullCardLayout,
|
||||||
useWideRatio,
|
useWideRatio,
|
||||||
@@ -1947,28 +1906,35 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
libraryDensity,
|
libraryDensity,
|
||||||
_shouldShowAlphaJumpBar,
|
_shouldShowAlphaJumpBar,
|
||||||
isPhone,
|
isPhone,
|
||||||
);
|
),
|
||||||
return SliverGrid.builder(
|
itemBuilder: (context, position) {
|
||||||
// Inert on media lists (no keep-alive clients): dropping the
|
final index = position.index;
|
||||||
// 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];
|
final item = loadedItems[index];
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
_scheduleRangeLoad();
|
_scheduleRangeLoad();
|
||||||
return const SkeletonMediaCard();
|
return const SkeletonMediaCard();
|
||||||
}
|
}
|
||||||
final cached = _cardMemo.tryGet(index, item, epoch: cardEpoch);
|
|
||||||
|
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 (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) &&
|
if (CardInflationBudget.isScrollingContext(context) &&
|
||||||
!InputModeTracker.isKeyboardMode(context) &&
|
!InputModeTracker.isKeyboardMode(context) &&
|
||||||
!CardInflationBudget.tryTake()) {
|
!CardInflationBudget.tryTake()) {
|
||||||
@@ -1978,23 +1944,19 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
return _cardMemo.widgetFor(
|
return _cardMemo.widgetFor(
|
||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
epoch: cardEpoch,
|
epoch: position.layoutEpoch!,
|
||||||
build: () => _buildMediaCardItem(
|
build: () => _buildMediaCardItem(
|
||||||
index,
|
index,
|
||||||
isFirstRow: GridSizeCalculator.isFirstRow(index, columnCount),
|
isFirstRow: position.isFirstRow,
|
||||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, columnCount),
|
isFirstColumn: position.isFirstColumn,
|
||||||
isLastColumn: (index % columnCount) == (columnCount - 1),
|
isLastColumn: position.isLastColumn,
|
||||||
columnCount: columnCount,
|
columnCount: position.columnCount,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMediaCardItem(
|
Widget _buildMediaCardItem(
|
||||||
|
|||||||
@@ -7,18 +7,16 @@ import '../../../mixins/library_tab_focus_mixin.dart';
|
|||||||
import '../../../mixins/paginated_item_loader.dart';
|
import '../../../mixins/paginated_item_loader.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/app_logger.dart';
|
import '../../../utils/app_logger.dart';
|
||||||
import '../../../utils/grid_size_calculator.dart';
|
|
||||||
import '../../../utils/layout_constants.dart';
|
import '../../../utils/layout_constants.dart';
|
||||||
import '../../../utils/library_refresh_notifier.dart';
|
import '../../../utils/library_refresh_notifier.dart';
|
||||||
import '../../../utils/media_server_http_client.dart';
|
import '../../../utils/media_server_http_client.dart';
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
import '../../../widgets/card_inflation_budget.dart';
|
import '../../../widgets/card_inflation_budget.dart';
|
||||||
import '../../../widgets/focusable_media_card.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/settings_builder.dart';
|
||||||
import '../../../widgets/skeleton_media_card.dart';
|
import '../../../widgets/skeleton_media_card.dart';
|
||||||
import '../../../widgets/sliver_child_memo.dart';
|
import '../../../widgets/sliver_child_memo.dart';
|
||||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
import '../../main_screen.dart';
|
import '../../main_screen.dart';
|
||||||
import 'base_library_tab.dart';
|
import 'base_library_tab.dart';
|
||||||
@@ -124,10 +122,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
||||||
if (viewMode == ViewMode.list)
|
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
|
||||||
_buildListSliver(density)
|
|
||||||
else
|
|
||||||
_buildGridSliver(density, fullCardLayout: fullCardLayout),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -141,62 +136,34 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
|||||||
return base.copyWith(top: base.top + _focusDecorationPadding);
|
return base.copyWith(top: base.top + _focusDecorationPadding);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildListSliver(int density) {
|
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
|
||||||
return SliverPadding(
|
return MediaCardSliverLayout(
|
||||||
padding: _effectivePadding,
|
viewMode: viewMode,
|
||||||
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,
|
itemCount: totalSize,
|
||||||
itemBuilder: (context, index) {
|
density: density,
|
||||||
|
padding: _effectivePadding,
|
||||||
|
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];
|
final item = loadedItems[index];
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
ensureIndexLoaded(index, pageSize: _pageSize);
|
ensureIndexLoaded(index, pageSize: _pageSize);
|
||||||
return const SkeletonMediaCard();
|
return const SkeletonMediaCard();
|
||||||
}
|
}
|
||||||
|
if (!position.isGrid) {
|
||||||
return _cardMemo.widgetFor(
|
return _cardMemo.widgetFor(
|
||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
epoch: (ViewMode.list, totalSize, density),
|
epoch: position.layoutEpoch!,
|
||||||
build: () => _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
|
build: () =>
|
||||||
);
|
_buildMediaCardItem(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
|
final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!);
|
||||||
return SliverPadding(
|
|
||||||
padding: _effectivePadding,
|
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
|
||||||
builder: (context, crossAxisExtent) {
|
|
||||||
final geometry = MediaGridGeometry.resolve(
|
|
||||||
context: context,
|
|
||||||
crossAxisExtent: crossAxisExtent,
|
|
||||||
density: density,
|
|
||||||
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;
|
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) &&
|
if (CardInflationBudget.isScrollingContext(context) &&
|
||||||
!InputModeTracker.isKeyboardMode(context) &&
|
!InputModeTracker.isKeyboardMode(context) &&
|
||||||
!CardInflationBudget.tryTake()) {
|
!CardInflationBudget.tryTake()) {
|
||||||
@@ -206,19 +173,16 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
|||||||
return _cardMemo.widgetFor(
|
return _cardMemo.widgetFor(
|
||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
epoch: cardEpoch,
|
epoch: position.layoutEpoch!,
|
||||||
build: () => _buildMediaCardItem(
|
build: () => _buildMediaCardItem(
|
||||||
index,
|
index,
|
||||||
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
|
isFirstRow: position.isFirstRow,
|
||||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
|
isFirstColumn: position.isFirstColumn,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMediaCardItem(
|
Widget _buildMediaCardItem(
|
||||||
|
|||||||
@@ -8,18 +8,16 @@ import '../../../mixins/library_tab_focus_mixin.dart';
|
|||||||
import '../../../mixins/paginated_item_loader.dart';
|
import '../../../mixins/paginated_item_loader.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/app_logger.dart';
|
import '../../../utils/app_logger.dart';
|
||||||
import '../../../utils/grid_size_calculator.dart';
|
|
||||||
import '../../../utils/layout_constants.dart';
|
import '../../../utils/layout_constants.dart';
|
||||||
import '../../../utils/library_refresh_notifier.dart';
|
import '../../../utils/library_refresh_notifier.dart';
|
||||||
import '../../../utils/media_server_http_client.dart';
|
import '../../../utils/media_server_http_client.dart';
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
import '../../../widgets/card_inflation_budget.dart';
|
import '../../../widgets/card_inflation_budget.dart';
|
||||||
import '../../../widgets/focusable_media_card.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/settings_builder.dart';
|
||||||
import '../../../widgets/skeleton_media_card.dart';
|
import '../../../widgets/skeleton_media_card.dart';
|
||||||
import '../../../widgets/sliver_child_memo.dart';
|
import '../../../widgets/sliver_child_memo.dart';
|
||||||
import '../../../widgets/sliver_cross_axis_layout_builder.dart';
|
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
import '../../main_screen.dart';
|
import '../../main_screen.dart';
|
||||||
import 'base_library_tab.dart';
|
import 'base_library_tab.dart';
|
||||||
@@ -129,10 +127,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)),
|
||||||
if (viewMode == ViewMode.list)
|
_buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout),
|
||||||
_buildListSliver(density)
|
|
||||||
else
|
|
||||||
_buildGridSliver(density, fullCardLayout: fullCardLayout),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -146,62 +141,34 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
|||||||
return base.copyWith(top: base.top + _focusDecorationPadding);
|
return base.copyWith(top: base.top + _focusDecorationPadding);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildListSliver(int density) {
|
Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) {
|
||||||
return SliverPadding(
|
return MediaCardSliverLayout(
|
||||||
padding: _effectivePadding,
|
viewMode: viewMode,
|
||||||
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,
|
itemCount: totalSize,
|
||||||
itemBuilder: (context, index) {
|
density: density,
|
||||||
|
padding: _effectivePadding,
|
||||||
|
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];
|
final playlist = loadedItems[index];
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
ensureIndexLoaded(index, pageSize: _pageSize);
|
ensureIndexLoaded(index, pageSize: _pageSize);
|
||||||
return const SkeletonMediaCard();
|
return const SkeletonMediaCard();
|
||||||
}
|
}
|
||||||
|
if (!position.isGrid) {
|
||||||
return _cardMemo.widgetFor(
|
return _cardMemo.widgetFor(
|
||||||
index,
|
index,
|
||||||
playlist,
|
playlist,
|
||||||
epoch: (ViewMode.list, totalSize, density),
|
epoch: position.layoutEpoch!,
|
||||||
build: () => _buildPlaylistCard(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
|
build: () =>
|
||||||
);
|
_buildPlaylistCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true),
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridSliver(int density, {required bool fullCardLayout}) {
|
final cached = _cardMemo.tryGet(index, playlist, epoch: position.layoutEpoch!);
|
||||||
return SliverPadding(
|
|
||||||
padding: _effectivePadding,
|
|
||||||
sliver: SliverCrossAxisLayoutBuilder(
|
|
||||||
builder: (context, crossAxisExtent) {
|
|
||||||
final geometry = MediaGridGeometry.resolve(
|
|
||||||
context: context,
|
|
||||||
crossAxisExtent: crossAxisExtent,
|
|
||||||
density: density,
|
|
||||||
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;
|
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) &&
|
if (CardInflationBudget.isScrollingContext(context) &&
|
||||||
!InputModeTracker.isKeyboardMode(context) &&
|
!InputModeTracker.isKeyboardMode(context) &&
|
||||||
!CardInflationBudget.tryTake()) {
|
!CardInflationBudget.tryTake()) {
|
||||||
@@ -211,19 +178,16 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
|||||||
return _cardMemo.widgetFor(
|
return _cardMemo.widgetFor(
|
||||||
index,
|
index,
|
||||||
playlist,
|
playlist,
|
||||||
epoch: cardEpoch,
|
epoch: position.layoutEpoch!,
|
||||||
build: () => _buildPlaylistCard(
|
build: () => _buildPlaylistCard(
|
||||||
index,
|
index,
|
||||||
isFirstRow: GridSizeCalculator.isFirstRow(index, geometry.columnCount),
|
isFirstRow: position.isFirstRow,
|
||||||
isFirstColumn: GridSizeCalculator.isFirstColumn(index, geometry.columnCount),
|
isFirstColumn: position.isFirstColumn,
|
||||||
fullBleedImage: fullCardLayout,
|
fullBleedImage: fullCardLayout,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaylistCard(
|
Widget _buildPlaylistCard(
|
||||||
|
|||||||
@@ -13,17 +13,15 @@ import '../../../mixins/deletion_aware.dart';
|
|||||||
import '../../../mixins/item_updatable.dart';
|
import '../../../mixins/item_updatable.dart';
|
||||||
import '../../../mixins/watch_state_aware.dart';
|
import '../../../mixins/watch_state_aware.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/debouncer.dart';
|
|
||||||
import '../../../utils/deletion_notifier.dart';
|
import '../../../utils/deletion_notifier.dart';
|
||||||
import '../../../utils/global_key_utils.dart';
|
import '../../../utils/global_key_utils.dart';
|
||||||
import '../../../utils/layout_constants.dart';
|
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
import '../../../utils/provider_extensions.dart';
|
import '../../../utils/provider_extensions.dart';
|
||||||
import '../../../utils/watch_state_notifier.dart';
|
import '../../../utils/watch_state_notifier.dart';
|
||||||
import '../../../widgets/hub_section.dart';
|
import '../../../widgets/hub_section.dart';
|
||||||
import '../../../widgets/settings_builder.dart';
|
import '../../../widgets/settings_builder.dart';
|
||||||
import '../../../widgets/tv_browse_rail.dart';
|
import '../../../widgets/tv_browse_rail.dart';
|
||||||
import '../../../widgets/tv_spotlight_background.dart';
|
import '../../../widgets/tv_spotlight_scaffold.dart';
|
||||||
import '../../main_screen.dart';
|
import '../../main_screen.dart';
|
||||||
import 'base_library_tab.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
|
/// GlobalKeys for each hub section to enable vertical navigation
|
||||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||||
// ValueNotifier (not setState) so a spotlight swap rebuilds only the
|
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||||
// 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));
|
|
||||||
|
|
||||||
MediaItem? get _defaultSpotlightItem {
|
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_spotlightDebouncer.dispose();
|
_spotlight.dispose();
|
||||||
_spotlightItem.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,78 +327,15 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
|
|
||||||
Widget _buildTvContent(List<MediaHub> items) {
|
Widget _buildTvContent(List<MediaHub> items) {
|
||||||
final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList();
|
final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList();
|
||||||
final size = MediaQuery.sizeOf(context);
|
return TvSpotlightScaffold(
|
||||||
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,
|
hubs: tvHubs,
|
||||||
density: svc.read(SettingsService.libraryDensity),
|
spotlightListenable: _spotlight,
|
||||||
episodePosterMode: svc.read(SettingsService.episodePosterMode),
|
resolveSpotlight: () => _spotlight.resolve(tvHubs),
|
||||||
fullCardLayout: svc.read(SettingsService.tvFullCardLayout),
|
resolveClient: (spotlight) =>
|
||||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
context.tryGetMediaClientForServer(serverIdOrNull(spotlight?.serverId ?? widget.library.serverId)),
|
||||||
);
|
foreground: tvHubs.isEmpty
|
||||||
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
|
? const SizedBox.shrink()
|
||||||
final minimumSpotlightBottom = railHeight + (8 * scale);
|
: Positioned(
|
||||||
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,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -449,9 +354,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-836
@@ -76,6 +76,10 @@ import 'plex_playback_mapper.dart';
|
|||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
|
|
||||||
part 'plex_client/parts/live_tv.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
|
/// Result of a paginated library content fetch
|
||||||
class _LibraryContentResult {
|
class _LibraryContentResult {
|
||||||
@@ -208,7 +212,13 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PlexClient
|
class PlexClient
|
||||||
with MediaServerCacheMixin, _PlexLiveTvClientMethods
|
with
|
||||||
|
MediaServerCacheMixin,
|
||||||
|
_PlexLiveTvClientMethods,
|
||||||
|
_PlexPlaylistMethods,
|
||||||
|
_PlexCollectionMethods,
|
||||||
|
_PlexPlayQueueMethods,
|
||||||
|
_PlexMetadataEditMethods
|
||||||
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
|
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
|
||||||
@override
|
@override
|
||||||
PlexConfig config;
|
PlexConfig config;
|
||||||
@@ -2083,717 +2093,6 @@ class PlexClient
|
|||||||
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
|
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
|
/// Extract both Metadata and Directory entries from response
|
||||||
/// Folders can come back as either type
|
/// Folders can come back as either type
|
||||||
/// Automatically tags all items with this client's serverId and serverName
|
/// 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.');
|
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.
|
/// Plex-specific: hub content as neutral [MediaItem]s.
|
||||||
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
|
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
|
||||||
final raw = await _getHubContent(hubKey);
|
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_, "hwdec", "auto");
|
||||||
}
|
}
|
||||||
mpv_set_option_string(mpv_, "keep-open", "yes");
|
mpv_set_option_string(mpv_, "keep-open", "yes");
|
||||||
|
mpv_set_option_string(mpv_, "audio-fallback-to-null", "yes");
|
||||||
|
|
||||||
if (!audio_only_) {
|
if (!audio_only_) {
|
||||||
// HDR tone mapping
|
// HDR tone mapping
|
||||||
mpv_set_option_string(mpv_, "tone-mapping", "auto");
|
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_, "hdr-compute-peak", "auto");
|
||||||
}
|
}
|
||||||
mpv_set_option_string(mpv_, "idle", "yes");
|
mpv_set_option_string(mpv_, "idle", "yes");
|
||||||
@@ -82,6 +83,8 @@ bool MpvPlayer::Initialize() {
|
|||||||
|
|
||||||
// Set up event wakeup callback.
|
// Set up event wakeup callback.
|
||||||
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this);
|
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");
|
g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
|
||||||
return true;
|
return true;
|
||||||
@@ -219,24 +222,12 @@ void MpvPlayer::Dispose() {
|
|||||||
event_callback_ = nullptr;
|
event_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cancel pending async requests
|
// 4. Cancel pending async requests.
|
||||||
std::vector<StatusCallback> status_callbacks;
|
auto cancelled = pending_requests_.CancelAll();
|
||||||
std::vector<GetPropertyCallback> get_callbacks;
|
for (auto& callback : cancelled.status) {
|
||||||
{
|
|
||||||
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) {
|
|
||||||
callback(-1);
|
callback(-1);
|
||||||
}
|
}
|
||||||
for (auto& callback : get_callbacks) {
|
for (auto& callback : cancelled.properties) {
|
||||||
callback(-1, "");
|
callback(-1, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +236,10 @@ void MpvPlayer::Dispose() {
|
|||||||
g_source_remove(event_source_id_);
|
g_source_remove(event_source_id_);
|
||||||
event_source_id_ = 0;
|
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.
|
// 6. Free render context and mpv handle in a background thread.
|
||||||
// mpv_render_context_free() can block waiting for mpv's render/VO thread,
|
// mpv_render_context_free() can block waiting for mpv's render/VO thread,
|
||||||
@@ -276,7 +271,7 @@ void MpvPlayer::Dispose() {
|
|||||||
}
|
}
|
||||||
}).detach();
|
}).detach();
|
||||||
|
|
||||||
observed_properties_.clear();
|
observed_properties_.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::Render(int width, int height, int fbo) {
|
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);
|
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());
|
int result = mpv_command_async(mpv_, request_id, c_args.data());
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeStatusRequest(request_id);
|
auto cb = pending_requests_.TakeStatus(request_id);
|
||||||
if (cb) cb(result);
|
if (cb) cb(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,12 +329,17 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val
|
|||||||
return;
|
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());
|
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);
|
int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeStatusRequest(request_id);
|
auto cb = pending_requests_.TakeStatus(request_id);
|
||||||
if (cb) cb(result);
|
if (cb) cb(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,72 +350,21 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca
|
|||||||
return;
|
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);
|
int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeGetPropertyRequest(request_id);
|
auto cb = pending_requests_.TakeProperty(request_id);
|
||||||
if (cb) cb(result, "");
|
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) {
|
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
|
||||||
if (disposed_ || !mpv_) return;
|
if (disposed_ || !mpv_) return;
|
||||||
|
|
||||||
if (observed_properties_.find(name) != observed_properties_.end()) {
|
const auto request = observed_properties_.Register(name, format, id);
|
||||||
return;
|
if (!request.added) return;
|
||||||
}
|
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::ReportMouseMove(int x, int y) {
|
void MpvPlayer::ReportMouseMove(int x, int y) {
|
||||||
@@ -503,12 +452,65 @@ bool MpvPlayer::ProcessEvents() {
|
|||||||
return true;
|
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) {
|
void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
||||||
switch (event->event_id) {
|
switch (event->event_id) {
|
||||||
case MPV_EVENT_COMMAND_REPLY:
|
case MPV_EVENT_COMMAND_REPLY:
|
||||||
case MPV_EVENT_SET_PROPERTY_REPLY: {
|
case MPV_EVENT_SET_PROPERTY_REPLY: {
|
||||||
uint64_t request_id = event->reply_userdata;
|
uint64_t request_id = event->reply_userdata;
|
||||||
StatusCallback callback = TakeStatusRequest(request_id);
|
StatusCallback callback = pending_requests_.TakeStatus(request_id);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
int error = event->error;
|
int error = event->error;
|
||||||
g_idle_add(
|
g_idle_add(
|
||||||
@@ -524,7 +526,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
}
|
}
|
||||||
case MPV_EVENT_GET_PROPERTY_REPLY: {
|
case MPV_EVENT_GET_PROPERTY_REPLY: {
|
||||||
uint64_t request_id = event->reply_userdata;
|
uint64_t request_id = event->reply_userdata;
|
||||||
GetPropertyCallback callback = TakeGetPropertyRequest(request_id);
|
GetPropertyCallback callback = pending_requests_.TakeProperty(request_id);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
int error = event->error;
|
int error = event->error;
|
||||||
std::string value;
|
std::string value;
|
||||||
@@ -587,10 +589,32 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
break;
|
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);
|
SendPropertyChange(prop->name, &node);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MPV_EVENT_END_FILE: {
|
case MPV_EVENT_END_FILE: {
|
||||||
|
audio_recovery_.SetFileLoaded(false);
|
||||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||||
FlValue* data = fl_value_new_map();
|
FlValue* data = fl_value_new_map();
|
||||||
fl_value_set_string_take(data, "reason", fl_value_new_int(static_cast<int>(end->reason)));
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case MPV_EVENT_FILE_LOADED: {
|
case MPV_EVENT_FILE_LOADED: {
|
||||||
|
audio_recovery_.SetFileLoaded(true);
|
||||||
SendEvent("file-loaded");
|
SendEvent("file-loaded");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -650,11 +675,11 @@ FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) {
|
|||||||
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
auto it = name_to_id_.find(name);
|
int id = 0;
|
||||||
if (it == name_to_id_.end()) return;
|
if (!observed_properties_.LookupId(name, &id)) return;
|
||||||
|
|
||||||
FlValue* list = fl_value_new_list();
|
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) {
|
if (data) {
|
||||||
fl_value_append_take(list, NodeToFlValue(data));
|
fl_value_append_take(list, NodeToFlValue(data));
|
||||||
} else {
|
} else {
|
||||||
@@ -683,4 +708,12 @@ void MpvPlayer::SendEvent(const std::string& name, FlValue* data) {
|
|||||||
fl_value_unref(event_map);
|
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
|
} // namespace mpv
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -18,6 +17,8 @@
|
|||||||
#include <tuple>
|
#include <tuple>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../../../native/mpv/mpv_player_common.h"
|
||||||
|
|
||||||
// Forward declaration for Flutter types
|
// Forward declaration for Flutter types
|
||||||
struct _FlValue;
|
struct _FlValue;
|
||||||
|
|
||||||
@@ -78,9 +79,9 @@ class MpvPlayer {
|
|||||||
void Command(const std::vector<std::string>& args);
|
void Command(const std::vector<std::string>& args);
|
||||||
|
|
||||||
/// Callback types for async mpv requests.
|
/// Callback types for async mpv requests.
|
||||||
using StatusCallback = std::function<void(int error)>;
|
using StatusCallback = plezy::mpv_common::StatusCallback;
|
||||||
using CommandCallback = 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.
|
/// Executes an mpv command asynchronously to prevent UI blocking.
|
||||||
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
|
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
|
||||||
@@ -136,11 +137,11 @@ class MpvPlayer {
|
|||||||
|
|
||||||
/// Sends an event notification.
|
/// Sends an event notification.
|
||||||
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
|
void SendEvent(const std::string& name, ::_FlValue* data = nullptr);
|
||||||
|
void MaybeRunAudioRecovery();
|
||||||
uint64_t RegisterStatusRequest(StatusCallback callback);
|
void TryAudioReload(const char* reason, int attempt);
|
||||||
StatusCallback TakeStatusRequest(uint64_t request_id);
|
void EnsureAudioRecoveryTimer();
|
||||||
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
|
void LogRecovery(const std::string& text);
|
||||||
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
|
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
|
||||||
|
|
||||||
/// Helper to convert mpv_node to FlValue.
|
/// Helper to convert mpv_node to FlValue.
|
||||||
::_FlValue* NodeToFlValue(mpv_node* node);
|
::_FlValue* NodeToFlValue(mpv_node* node);
|
||||||
@@ -158,18 +159,14 @@ class MpvPlayer {
|
|||||||
EventCallback event_callback_;
|
EventCallback event_callback_;
|
||||||
RedrawCallback redraw_callback_;
|
RedrawCallback redraw_callback_;
|
||||||
std::mutex callback_mutex_;
|
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;
|
// GLib sources for event delivery and scheduled audio recovery.
|
||||||
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
|
|
||||||
guint event_source_id_ = 0;
|
guint event_source_id_ = 0;
|
||||||
|
guint recovery_source_id_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mpv
|
} // 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.progress, 75);
|
||||||
expect(activities.last.cancellable, isTrue);
|
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 <windowsx.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
#include "sanitize_utf8.h"
|
#include "sanitize_utf8.h"
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
namespace {
|
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) {
|
flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) {
|
||||||
if (!node) return flutter::EncodableValue();
|
if (!node) return flutter::EncodableValue();
|
||||||
|
|
||||||
@@ -176,7 +159,7 @@ bool MpvPlayer::Initialize(HWND view) {
|
|||||||
|
|
||||||
if (!audio_only_) {
|
if (!audio_only_) {
|
||||||
// Let mpv use display/context detection instead of forcing HDR signaling.
|
// 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
|
// Fallback tone mapping when display doesn't support HDR
|
||||||
mpv_set_option_string(mpv_, "tone-mapping", "auto");
|
mpv_set_option_string(mpv_, "tone-mapping", "auto");
|
||||||
@@ -203,10 +186,6 @@ bool MpvPlayer::Initialize(HWND view) {
|
|||||||
return false;
|
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);
|
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
|
||||||
// Native observation so audio recovery doesn't depend on the Dart side
|
// Native observation so audio recovery doesn't depend on the Dart side
|
||||||
// choosing to observe the device list.
|
// choosing to observe the device list.
|
||||||
@@ -221,24 +200,11 @@ bool MpvPlayer::Initialize(HWND view) {
|
|||||||
void MpvPlayer::Dispose() {
|
void MpvPlayer::Dispose() {
|
||||||
StopEventLoop();
|
StopEventLoop();
|
||||||
|
|
||||||
// Cancel pending async requests
|
auto cancelled = pending_requests_.CancelAll();
|
||||||
std::vector<StatusCallback> status_callbacks;
|
for (auto& callback : cancelled.status) {
|
||||||
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) {
|
|
||||||
callback(-1);
|
callback(-1);
|
||||||
}
|
}
|
||||||
for (auto& callback : get_callbacks) {
|
for (auto& callback : cancelled.properties) {
|
||||||
callback(-1, "");
|
callback(-1, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +231,7 @@ void MpvPlayer::Dispose() {
|
|||||||
std::thread([handle]() { mpv_terminate_destroy(handle); }).detach();
|
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); }
|
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);
|
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
|
// mpv_command_async returns immediately
|
||||||
int result = mpv_command_async(mpv_, request_id, c_args.data());
|
int result = mpv_command_async(mpv_, request_id, c_args.data());
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeStatusRequest(request_id);
|
auto cb = pending_requests_.TakeStatus(request_id);
|
||||||
if (cb) cb(result);
|
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)
|
// Handle custom HDR toggle property (same pattern as iOS/macOS)
|
||||||
if (name == "hdr-enabled") {
|
if (name == "hdr-enabled") {
|
||||||
bool enabled = (value == "yes" || value == "true" || value == "1");
|
SetHDREnabled(plezy::mpv_common::ParseEnabledFlag(value), std::move(callback));
|
||||||
SetHDREnabled(enabled, std::move(callback));
|
|
||||||
return;
|
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());
|
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);
|
int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeStatusRequest(request_id);
|
auto cb = pending_requests_.TakeStatus(request_id);
|
||||||
if (cb) cb(result);
|
if (cb) cb(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,73 +291,21 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca
|
|||||||
return;
|
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);
|
int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING);
|
||||||
if (result < 0) {
|
if (result < 0) {
|
||||||
auto cb = TakeGetPropertyRequest(request_id);
|
auto cb = pending_requests_.TakeProperty(request_id);
|
||||||
if (cb) cb(result, "");
|
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) {
|
void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) {
|
||||||
if (!mpv_) return;
|
if (!mpv_) return;
|
||||||
|
|
||||||
// Check if already observing.
|
const auto request = observed_properties_.Register(name, format, id);
|
||||||
if (observed_properties_.find(name) != observed_properties_.end()) {
|
if (!request.added) return;
|
||||||
return;
|
mpv_observe_property(mpv_, request.userdata, name.c_str(), request.format);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::SetRect(RECT rect, double device_pixel_ratio) {
|
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::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) {
|
void MpvPlayer::LogRecovery(const std::string& text) {
|
||||||
char log_msg[512];
|
char log_msg[512];
|
||||||
@@ -446,52 +359,25 @@ void MpvPlayer::LogRecovery(const std::string& text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::TryAudioReload(const char* reason, int attempt) {
|
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) + ")");
|
LogRecovery("issuing ao-reload (reason=" + std::string(reason) + ", attempt " + std::to_string(attempt) + ")");
|
||||||
std::string reason_str = reason;
|
const std::string reason_copy = reason;
|
||||||
CommandAsync({"ao-reload"}, [this, reason_str, attempt](int error) {
|
CommandAsync({"ao-reload"}, [this, reason_copy, attempt](int error) {
|
||||||
audio_reload_pending_ = false;
|
audio_recovery_.CompleteReload();
|
||||||
LogRecovery(
|
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) + ")");
|
", error=" + std::to_string(error) + ")");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void MpvPlayer::MaybeRunAudioRecovery() {
|
void MpvPlayer::MaybeRunAudioRecovery() {
|
||||||
const auto now = std::chrono::steady_clock::now();
|
const auto action = audio_recovery_.NextReload(plezy::mpv_common::AudioRecoveryState::Clock::now());
|
||||||
|
if (action.reason == plezy::mpv_common::AudioReloadReason::kNone) {
|
||||||
if (resume_reload_requested_.exchange(false)) {
|
return;
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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_COMMAND_REPLY:
|
||||||
case MPV_EVENT_SET_PROPERTY_REPLY: {
|
case MPV_EVENT_SET_PROPERTY_REPLY: {
|
||||||
uint64_t request_id = event->reply_userdata;
|
uint64_t request_id = event->reply_userdata;
|
||||||
StatusCallback callback = TakeStatusRequest(request_id);
|
StatusCallback callback = pending_requests_.TakeStatus(request_id);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(event->error);
|
callback(event->error);
|
||||||
}
|
}
|
||||||
@@ -539,7 +425,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
}
|
}
|
||||||
case MPV_EVENT_GET_PROPERTY_REPLY: {
|
case MPV_EVENT_GET_PROPERTY_REPLY: {
|
||||||
uint64_t request_id = event->reply_userdata;
|
uint64_t request_id = event->reply_userdata;
|
||||||
GetPropertyCallback callback = TakeGetPropertyRequest(request_id);
|
GetPropertyCallback callback = pending_requests_.TakeProperty(request_id);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
std::string value;
|
std::string value;
|
||||||
if (event->error >= 0) {
|
if (event->error >= 0) {
|
||||||
@@ -594,44 +480,22 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
break;
|
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) {
|
if (strcmp(prop->name, "current-ao") == 0) {
|
||||||
const char* current_ao = nullptr;
|
const char* current_ao = nullptr;
|
||||||
if (prop->format == MPV_FORMAT_STRING && prop->data) {
|
if (prop->format == MPV_FORMAT_STRING && prop->data) {
|
||||||
current_ao = *static_cast<char**>(prop->data);
|
current_ao = *static_cast<char**>(prop->data);
|
||||||
}
|
}
|
||||||
bool is_null = current_ao && strcmp(current_ao, "null") == 0;
|
const bool is_null = current_ao && strcmp(current_ao, "null") == 0;
|
||||||
if (is_null && !current_ao_is_null_) {
|
const auto transition =
|
||||||
// AO fell back to null (audio-fallback-to-null); start recovery.
|
audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now());
|
||||||
null_attempts_left_ = kNullRetryBudget;
|
if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) {
|
||||||
null_backoff_ = kNullFirstDelay;
|
LogRecovery("current-ao fell back to null; starting recovery");
|
||||||
null_next_attempt_ = std::chrono::steady_clock::now() + kNullFirstDelay;
|
} else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) {
|
||||||
LogRecovery(
|
LogRecovery("audio recovered (current-ao no longer null)");
|
||||||
"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 : "") + "'");
|
|
||||||
}
|
}
|
||||||
current_ao_is_null_ = is_null;
|
|
||||||
}
|
}
|
||||||
|
if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 &&
|
||||||
// A device (re)appearing while the AO sits on the null fallback is a
|
audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) {
|
||||||
// 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;
|
|
||||||
LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload");
|
LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,9 +503,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MPV_EVENT_END_FILE: {
|
case MPV_EVENT_END_FILE: {
|
||||||
file_loaded_ = false;
|
audio_recovery_.SetFileLoaded(false);
|
||||||
resume_attempts_left_ = 0;
|
|
||||||
null_attempts_left_ = 0;
|
|
||||||
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
auto* end = static_cast<mpv_event_end_file*>(event->data);
|
||||||
flutter::EncodableMap data;
|
flutter::EncodableMap data;
|
||||||
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
|
data[flutter::EncodableValue("reason")] = flutter::EncodableValue(static_cast<int>(end->reason));
|
||||||
@@ -653,7 +515,7 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case MPV_EVENT_FILE_LOADED: {
|
case MPV_EVENT_FILE_LOADED: {
|
||||||
file_loaded_ = true;
|
audio_recovery_.SetFileLoaded(true);
|
||||||
SendEvent("file-loaded");
|
SendEvent("file-loaded");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -673,13 +535,13 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) {
|
|||||||
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
auto it = name_to_id_.find(name);
|
int id = 0;
|
||||||
if (it == name_to_id_.end()) return;
|
if (!observed_properties_.LookupId(name, &id)) return;
|
||||||
|
|
||||||
// mpv owns event node storage; copy the full tree before the callback can
|
// mpv owns event node storage; copy the full tree before the callback can
|
||||||
// queue it beyond the current mpv_wait_event result's lifetime.
|
// queue it beyond the current mpv_wait_event result's lifetime.
|
||||||
flutter::EncodableList list;
|
flutter::EncodableList list;
|
||||||
list.push_back(flutter::EncodableValue(it->second));
|
list.push_back(flutter::EncodableValue(id));
|
||||||
list.push_back(NodeToEncodableValue(data));
|
list.push_back(NodeToEncodableValue(data));
|
||||||
|
|
||||||
std::lock_guard<std::mutex> lock(callback_mutex_);
|
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) {
|
void MpvPlayer::SetHDREnabled(bool enabled, StatusCallback callback) {
|
||||||
hdr_enabled_ = enabled;
|
hdr_enabled_ = enabled;
|
||||||
|
if (!mpv_) {
|
||||||
if (mpv_) {
|
if (callback) callback(0);
|
||||||
SetPropertyAsync("target-colorspace-hint", enabled ? "auto" : "no", std::move(callback));
|
return;
|
||||||
} else if (callback) {
|
|
||||||
callback(0);
|
|
||||||
}
|
}
|
||||||
|
SetPropertyAsync("target-colorspace-hint", plezy::mpv_common::TargetColorspaceHint(enabled), std::move(callback));
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../../../native/mpv/mpv_player_common.h"
|
||||||
|
|
||||||
namespace mpv {
|
namespace mpv {
|
||||||
|
|
||||||
// Wrapper for libmpv that handles initialization, commands, properties,
|
// Wrapper for libmpv that handles initialization, commands, properties,
|
||||||
@@ -45,9 +46,9 @@ class MpvPlayer {
|
|||||||
void Command(const std::vector<std::string>& args);
|
void Command(const std::vector<std::string>& args);
|
||||||
|
|
||||||
// Callback types for async mpv requests.
|
// Callback types for async mpv requests.
|
||||||
using StatusCallback = std::function<void(int error)>;
|
using StatusCallback = plezy::mpv_common::StatusCallback;
|
||||||
using CommandCallback = 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.
|
// Executes an mpv command asynchronously to prevent UI blocking.
|
||||||
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
|
void CommandAsync(const std::vector<std::string>& args, CommandCallback callback);
|
||||||
@@ -95,10 +96,6 @@ class MpvPlayer {
|
|||||||
void MaybeRunAudioRecovery();
|
void MaybeRunAudioRecovery();
|
||||||
void TryAudioReload(const char* reason, int attempt);
|
void TryAudioReload(const char* reason, int attempt);
|
||||||
void LogRecovery(const std::string& text);
|
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_;
|
const bool audio_only_;
|
||||||
mpv_handle* mpv_ = nullptr;
|
mpv_handle* mpv_ = nullptr;
|
||||||
@@ -108,35 +105,15 @@ class MpvPlayer {
|
|||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
EventCallback event_callback_;
|
EventCallback event_callback_;
|
||||||
std::mutex callback_mutex_;
|
std::mutex callback_mutex_;
|
||||||
bool current_ao_is_null_ = false;
|
plezy::mpv_common::AudioRecoveryState audio_recovery_;
|
||||||
bool audio_reload_pending_ = false;
|
|
||||||
|
|
||||||
// Audio recovery state. Event thread only, except |resume_reload_requested_|
|
plezy::mpv_common::AsyncRequestRegistry pending_requests_;
|
||||||
// which the platform thread sets on WM_POWERBROADCAST resume.
|
plezy::mpv_common::PropertyObservationRegistry observed_properties_;
|
||||||
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_;
|
|
||||||
|
|
||||||
// HDR state
|
// HDR state
|
||||||
bool hdr_enabled_ = true; // User preference
|
bool hdr_enabled_ = true;
|
||||||
double last_sig_peak_ = 0.0; // Last known sig-peak for HDR content detection
|
|
||||||
|
|
||||||
// HDR methods
|
|
||||||
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
|
void SetHDREnabled(bool enabled, StatusCallback callback = nullptr);
|
||||||
void UpdateHDRMode(double sigPeak);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mpv
|
} // namespace mpv
|
||||||
|
|||||||
Reference in New Issue
Block a user