From 83f4e2a2639bb6cb3aa788de40d2abe8648ffddd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:36:56 +0200 Subject: [PATCH] refactor: fold single-use helpers into their call sites Collapses indirection layers and one-caller abstractions across the video player, shortcut dispatch, shader loading and context-menu code, including the VideoPIPManager pass-through over PipService. --- lib/screens/livetv/record_options_sheet.dart | 158 +++++--------- .../parts/episode_navigation.dart | 2 +- lib/screens/video_player/parts/pip.dart | 95 +++++++- .../video_player/parts/playback_prompts.dart | 2 +- .../video_player/parts/playback_services.dart | 11 +- .../video_player/parts/playback_start.dart | 5 +- lib/screens/video_player_screen.dart | 8 +- lib/services/data_aggregation_service.dart | 139 ++++++------ lib/services/keyboard_shortcuts_service.dart | 200 ++++++----------- lib/services/shader_asset_loader.dart | 103 +++------ lib/services/shortcut_action.dart | 5 +- lib/services/video_pip_manager.dart | 91 -------- lib/widgets/media_context_menu.dart | 205 ++++++++++-------- .../video_controls/parts/key_events.dart | 2 - test/services/shader_asset_loader_test.dart | 15 ++ 15 files changed, 443 insertions(+), 598 deletions(-) delete mode 100644 lib/services/video_pip_manager.dart diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 4251ce14..f69e0be0 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -419,9 +419,23 @@ class _SettingRow extends StatelessWidget { return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); } if (type == 'int') { - return _IntSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + parseValue: int.tryParse, + onChanged: onChanged, + ); } - return _TextSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + parseValue: (text) => text, + onChanged: onChanged, + ); } } @@ -435,6 +449,28 @@ bool _coerceBool(Object? value) { return false; } +/// Setting label with its optional secondary summary line. +class _SettingLabel extends StatelessWidget { + final String label; + final String? summary; + + const _SettingLabel({required this.label, this.summary}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final summary = this.summary; + return Column( + crossAxisAlignment: .start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + if (summary != null && summary.isNotEmpty) + Text(summary, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + ); + } +} + class _BoolSettingRow extends StatelessWidget { final SubscriptionSetting setting; final Object? currentValue; @@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final value = _coerceBool(currentValue); void toggle() => onChanged(!value); return FocusableWrapper( @@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium), - if (setting.summary != null && setting.summary!.isNotEmpty) - Text( - setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], - ), + child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary), ), IgnorePointer( child: Switch(value: value, onChanged: (v) => onChanged(v)), @@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(label, style: theme.textTheme.bodyMedium), - if (summary != null && summary!.isNotEmpty) - Text(summary!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - ], - ), + child: _SettingLabel(label: label, summary: summary), ), const SizedBox(width: 12), Text(value, style: theme.textTheme.bodyMedium), @@ -575,24 +593,32 @@ class _PickerRow extends StatelessWidget { } } -class _IntSettingRow extends StatefulWidget { +/// Free-text setting row. [parseValue] maps the field text to the value handed +/// back to [onChanged] — identity for text settings, `int.tryParse` for ints. +class _TextFieldSettingRow extends StatefulWidget { final SubscriptionSetting setting; final Object? currentValue; final bool autofocus; + final TextInputType? keyboardType; + final List? inputFormatters; + final Object? Function(String) parseValue; final void Function(Object?) onChanged; - const _IntSettingRow({ + const _TextFieldSettingRow({ required this.setting, required this.currentValue, required this.autofocus, + required this.parseValue, required this.onChanged, + this.keyboardType, + this.inputFormatters, }); @override - State<_IntSettingRow> createState() => _IntSettingRowState(); + State<_TextFieldSettingRow> createState() => _TextFieldSettingRowState(); } -class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin { +class _TextFieldSettingRowState extends State<_TextFieldSettingRow> with ControllerDisposerMixin { late final TextEditingController _controller; @override @@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM } @override - void didUpdateWidget(covariant _IntSettingRow oldWidget) { + void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) { super.didUpdateWidget(oldWidget); final next = widget.currentValue?.toString() ?? ''; if (next != _controller.text) _controller.text = next; @@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( crossAxisAlignment: .start, children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), + _SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary), const SizedBox(height: 4), FocusableTextField( controller: _controller, autofocus: widget.autofocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, onNavigateUp: () => FocusScope.of(context).previousFocus(), onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) { - final parsed = int.tryParse(text); - widget.onChanged(parsed); - }, - ), - ], - ), - ); - } -} - -class _TextSettingRow extends StatefulWidget { - final SubscriptionSetting setting; - final Object? currentValue; - final bool autofocus; - final void Function(Object?) onChanged; - - const _TextSettingRow({ - required this.setting, - required this.currentValue, - required this.autofocus, - required this.onChanged, - }); - - @override - State<_TextSettingRow> createState() => _TextSettingRowState(); -} - -class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = createTextEditingController(text: widget.currentValue?.toString() ?? ''); - } - - @override - void didUpdateWidget(covariant _TextSettingRow oldWidget) { - super.didUpdateWidget(oldWidget); - final next = widget.currentValue?.toString() ?? ''; - if (next != _controller.text) _controller.text = next; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Column( - crossAxisAlignment: .start, - children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 4), - FocusableTextField( - controller: _controller, - autofocus: widget.autofocus, - onNavigateUp: () => FocusScope.of(context).previousFocus(), - onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) => widget.onChanged(text), + onChanged: (text) => widget.onChanged(widget.parseValue(text)), ), ], ), diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index a4e88b73..76999c99 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -817,7 +817,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (!isCurrentReload()) return _MediaReloadOutcome.superseded; if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); + unawaited(_updateAutoPipState(isPlaying: currentPlayer.state.playing)); } return _MediaReloadOutcome.opened; } catch (e) { diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index aa05c811..f52a1dfc 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -19,12 +19,12 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { _autoPipEnteringCallback = null; } - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. + /// Initialize VideoFilterManager and the PiP methods if not already set up. /// Called from both live TV and VOD playback paths. Future _initVideoFilterAndPip() async { final currentPlayer = player; if (!mounted || currentPlayer == null) return; - if (_videoFilterManager != null && _videoPIPManager != null) { + if (_videoFilterManager != null && _pipInitialized) { _attachPipStateListener(); return; } @@ -44,20 +44,91 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { unawaited(_videoFilterManager!.updateVideoFilter()); } - _videoPIPManager ??= VideoPIPManager( - player: currentPlayer, - playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null, - ); - _videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry; + _pipInitialized = true; _attachPipStateListener(); } Future _togglePIPMode() async { - final result = await _videoPIPManager?.togglePIP(); - if (result != null && !result.$1 && mounted) { - _restorePipFiltersAfterExit(); - showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed); + if (!_pipInitialized) return; + + final supported = await PipService.isSupported(); + if (!supported) { + _onPipRequestFailed('PiP not supported on this device'); + return; } + + // If PiP is already active, exit it + if (PipService().isPipActive.value) { + await PipService.exit(); + return; + } + + // Reset video filter to contain mode before entering PiP. Android, iOS, + // and macOS all reuse the inline video surface/layer for PiP. + if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { + if (_pipInitialized) _preparePipFiltersForEntry(); + // Wait a frame for the filter change to take effect + await Future.delayed(const Duration(milliseconds: 50)); + } + + final dims = await _getVideoDimensions(); + final result = await PipService.enter(width: dims.$1, height: dims.$2); + if (!result.$1) _onPipRequestFailed(result.$2); + } + + void _onPipRequestFailed(String? error) { + if (!mounted) return; + _restorePipFiltersAfterExit(); + showErrorSnackBar(context, error ?? t.videoControls.pipFailed); + } + + Future _updateAutoPipState({required bool isPlaying}) async { + if (!_pipInitialized) return; + + if (!isPlaying) { + await PipService.setAutoPipReady(ready: false); + return; + } + + final dims = await _getVideoDimensions(); + await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); + } + + /// Get current video dimensions (display or storage or fallback to viewport) + Future<(int? width, int? height)> _getVideoDimensions() async { + final currentPlayer = player; + int? width; + int? height; + + try { + final dwidth = await currentPlayer?.getProperty('dwidth'); + final dheight = await currentPlayer?.getProperty('dheight'); + if (dwidth != null && dheight != null) { + width = int.tryParse(dwidth); + height = int.tryParse(dheight); + } + } catch (e) { + appLogger.d('PiP: dwidth/dheight unavailable', error: e); + } + + if (width == null || height == null) { + try { + final videoWidth = await currentPlayer?.getProperty('width'); + final videoHeight = await currentPlayer?.getProperty('height'); + if (videoWidth != null && videoHeight != null) { + width = int.tryParse(videoWidth); + height = int.tryParse(videoHeight); + } + } catch (e) { + appLogger.d('PiP: width/height unavailable', error: e); + } + } + + final viewport = _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null; + width ??= viewport?.width.toInt(); + height ??= viewport?.height.toInt(); + + return (width, height); } void _preparePipFiltersForEntry() { @@ -99,7 +170,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); - if (_videoPIPManager == null || _videoFilterManager == null) return; + if (!_pipInitialized || _videoFilterManager == null) return; if (isInPip) { _preparePipFiltersForEntry(); diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index f25b8a50..4b07fe75 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -24,7 +24,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { unawaited(DiscordRPCService.instance.pausePlayback()); unawaited(TraktScrobbleService.instance.pausePlayback()); if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false)); + unawaited(_updateAutoPipState(isPlaying: false)); } // End-of-video sleep timer takes precedence over autoplay / next-episode diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index dcafffe7..967b17ea 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -267,12 +267,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _stopLiveTimelineUpdates(); _detachPipStateListener(); _clearAutoPipEnteringCallback(); - final videoPipManager = _videoPIPManager; - _videoPIPManager = null; - if (videoPipManager != null) { - videoPipManager.onBeforeEnterPip = null; + final pipInitialized = _pipInitialized; + _pipInitialized = false; + if (pipInitialized) { try { - await videoPipManager.disableAutoPip(); + await PipService.setAutoPipReady(ready: false); } catch (e, st) { appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: st); } @@ -632,7 +631,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Update auto-PiP readiness if (_autoPipEnabled) { - _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); + unawaited(_updateAutoPipState(isPlaying: isPlaying)); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 4ece6685..27a5738d 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -305,9 +305,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _autoPipEnteringCallback = autoPipEnteringCallback; PipService.onAutoPipEntering = autoPipEnteringCallback; - final pipManager = _videoPIPManager; - if (currentPlayer.state.playing && pipManager != null) { - unawaited(pipManager.updateAutoPipState(isPlaying: true)); + if (currentPlayer.state.playing) { + unawaited(_updateAutoPipState(isPlaying: true)); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 03924649..030cecb1 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -65,7 +65,6 @@ import '../services/track_manager.dart'; import '../services/track_selection_service.dart'; import '../services/ambient_lighting_service.dart'; import '../services/video_filter_manager.dart'; -import '../services/video_pip_manager.dart'; import '../services/video_volume_controller.dart'; import '../services/pip_service.dart'; import '../models/shader_preset.dart'; @@ -525,7 +524,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority; PlaybackProgressTracker? _progressTracker; VideoFilterManager? _videoFilterManager; - VideoPIPManager? _videoPIPManager; + bool _pipInitialized = false; ShaderService? _shaderService; AmbientLightingService? _ambientLightingService; bool _fullscreenListenerAttached = false; @@ -1517,11 +1516,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _stopLiveTimelineUpdates(); _detachPipStateListener(); - _videoPIPManager?.onBeforeEnterPip = null; - unawaited(_videoPIPManager?.disableAutoPip()); + if (_pipInitialized) unawaited(PipService.setAutoPipReady(ready: false)); _clearAutoPipEnteringCallback(); _videoFilterManager?.dispose(); - _videoPIPManager = null; + _pipInitialized = false; _videoFilterManager = null; _scrubPreviewSource?.dispose(); diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 99456a41..2410681a 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -25,6 +25,7 @@ typedef LibraryAggregationResult = ({ Set succeededServerIds, Set cancelledServerIds, }); +typedef _FanOutResult = ({List items, Set succeededServerIds, Set cancelledServerIds}); /// Whether [error] is a client-side abort (client teardown mid-request) /// rather than a genuine server failure. Aggregation reports these servers @@ -54,6 +55,37 @@ class DataAggregationService { }; } + /// Run [fetch] against every client in [clients] and concatenate the results + /// in client order. A per-server failure is swallowed — logged with + /// [failureMessage] and contributing nothing — so one bad server cannot sink + /// the pass; that server is simply absent from `succeededServerIds`, and also + /// lands in `cancelledServerIds` when the failure was a client-side abort. + Future<_FanOutResult> _fanOut( + Map clients, { + required String Function(String serverId) failureMessage, + required Future> Function(String serverId, MediaServerClient client) fetch, + }) async { + final cancelledServerIds = {}; + final futures = clients.entries.map((entry) async { + try { + return (serverId: entry.key, items: await fetch(entry.key, entry.value)); + } catch (e, stackTrace) { + if (_isCancellation(e)) cancelledServerIds.add(entry.key); + appLogger.e(failureMessage(entry.key), error: e, stackTrace: stackTrace); + return (serverId: null, items: []); + } + }); + final results = await Future.wait(futures); + return ( + items: [for (final result in results) ...result.items], + succeededServerIds: { + for (final result in results) + if (result.serverId != null) result.serverId!, + }, + cancelledServerIds: cancelledServerIds, + ); + } + /// Fetch libraries from all online clients regardless of backend, returning /// the merged neutral [MediaLibrary]s alongside the ids of the servers whose /// fetch actually succeeded. [serverIds] restricts the fan-out to those @@ -77,24 +109,15 @@ class DataAggregationService { cancelledServerIds: const {}, ); } - final succeededServerIds = {}; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - try { - final libraries = await entry.value.fetchLibraries(); - succeededServerIds.add(entry.key); - return libraries; - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed neutral library fetch from ${entry.key}', error: e, stackTrace: stackTrace); - return []; - } - }); - final results = await Future.wait(futures); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed neutral library fetch from $serverId', + fetch: (_, client) => client.fetchLibraries(), + ); return ( - libraries: [for (final list in results) ...list], - succeededServerIds: succeededServerIds, - cancelledServerIds: cancelledServerIds, + libraries: fetched.items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, ); } @@ -113,24 +136,12 @@ class DataAggregationService { return (items: const [], succeededServerIds: const {}, cancelledServerIds: const {}); } - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final client = entry.value; - try { - final items = await client.fetchContinueWatching(count: limit); - return (serverId: entry.key, items: items); - } catch (e, st) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed on-deck fetch from ${entry.key}', error: e, stackTrace: st); - return (serverId: null, items: []); - } - }); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final allOnDeck = results.expand((result) => result.items).toList(); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed on-deck fetch from $serverId', + fetch: (_, client) => client.fetchContinueWatching(count: limit), + ); + final allOnDeck = fetched.items; // Filter out items from hidden libraries List filteredOnDeck = allOnDeck; @@ -154,7 +165,11 @@ class DataAggregationService { appLogger.i('Fetched ${items.length} on deck items from all servers'); - return (items: items, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return ( + items: items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, + ); } /// Merge an [existing] Continue Watching list with [fresh] rows from @@ -382,11 +397,10 @@ class DataAggregationService { ? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries) : null; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - try { + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed to fetch hubs from server $serverId', + fetch: (serverId, client) async { final serverLibraries = libraries?[serverId]; final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs; final hubItemLimit = limit ?? defaultHubPreviewLimit; @@ -413,28 +427,13 @@ class DataAggregationService { includePlaybackHubs: includePlaybackHubs, libraries: useGlobalHubs ? serverLibraries : null, ); - return ( - serverId: serverId, - hubs: _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys), - ); - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(serverId); - appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); - return (serverId: null, hubs: []); - } - }); + return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys); + }, + ); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final all = []; - for (final result in results) { - all.addAll(result.hubs); - } + final all = fetched.items; final hubs = limit != null && limit < all.length ? all.sublist(0, limit) : all; - return (hubs: hubs, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return (hubs: hubs, succeededServerIds: fetched.succeededServerIds, cancelledServerIds: fetched.cancelledServerIds); } /// Per-library hub fetch for a single client. Filters to visible libraries @@ -519,18 +518,12 @@ class DataAggregationService { final resultLimit = limit ?? defaultMediaSearchLimit; final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit; - final futures = clients.entries.map((entry) async { - final client = entry.value; - try { - return await client.searchItems(query, limit: fetchLimit); - } catch (e, st) { - appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st); - return []; - } - }); - - final allResults = (await Future.wait(futures)).expand((l) => l).toList(); - final result = rankMediaSearchResults(allResults, query, limit: resultLimit); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Search failed on $serverId', + fetch: (_, client) => client.searchItems(query, limit: fetchLimit), + ); + final result = rankMediaSearchResults(fetched.items, query, limit: resultLimit); appLogger.i('Found ${result.length} search results across all servers'); diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 52d79410..448794c8 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -202,8 +202,6 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onVolumeUp, VoidCallback? onVolumeDown, VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, ValueChanged? onLiveSeekBy, Future Function(Duration position)? onSeekRequested, }) { @@ -277,32 +275,78 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.handled; } - _executeAction( - action, - player, - onToggleFullscreen, - onToggleSubtitles, - onNextAudioTrack, - onNextSubtitleTrack, - onNextChapter, - onPreviousChapter, - onPlayPause: onPlayPause, - onToggleShader: onToggleShader, - onSkipMarker: onSkipMarker, - onNextEpisode: onNextEpisode, - onPreviousEpisode: onPreviousEpisode, - onScreenshot: onScreenshot, - onZoomIn: onZoomIn, - onZoomOut: onZoomOut, - onZoomReset: onZoomReset, - onVolumeUp: onVolumeUp, - onVolumeDown: onVolumeDown, - onToggleMute: onToggleMute, - currentPositionEpoch: currentPositionEpoch, - onLiveSeek: onLiveSeek, - onLiveSeekBy: onLiveSeekBy, - onSeekRequested: onSeekRequested, - ); + void performSeek(int offsetSeconds) { + // Relative live-TV skip: route through the parent accumulator, which + // coalesces a rapid burst into one transcode re-open (#1253). + if (onLiveSeekBy != null) { + onLiveSeekBy(offsetSeconds); + } else { + final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); + unawaited((onSeekRequested ?? player.seek)(target)); + } + } + + switch (action) { + case ShortcutAction.playPause: + (onPlayPause ?? player.playOrPause).call(); + case ShortcutAction.volumeUp: + onVolumeUp?.call(); + case ShortcutAction.volumeDown: + onVolumeDown?.call(); + case ShortcutAction.seekForward: + performSeek(_seekTimeSmall); + case ShortcutAction.seekBackward: + performSeek(-_seekTimeSmall); + case ShortcutAction.seekForwardLarge: + performSeek(_seekTimeLarge); + case ShortcutAction.seekBackwardLarge: + performSeek(-_seekTimeLarge); + case ShortcutAction.fullscreenToggle: + onToggleFullscreen?.call(); + case ShortcutAction.muteToggle: + onToggleMute?.call(); + case ShortcutAction.subtitleToggle: + onToggleSubtitles?.call(); + case ShortcutAction.audioTrackNext: + onNextAudioTrack?.call(); + case ShortcutAction.subtitleTrackNext: + onNextSubtitleTrack?.call(); + case ShortcutAction.chapterNext: + onNextChapter?.call(); + case ShortcutAction.chapterPrevious: + onPreviousChapter?.call(); + case ShortcutAction.episodeNext: + onNextEpisode?.call(); + case ShortcutAction.episodePrevious: + onPreviousEpisode?.call(); + case ShortcutAction.speedIncrease: + final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); + player.setRate(newRateUp); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); + case ShortcutAction.speedDecrease: + final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); + player.setRate(newRateDown); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); + case ShortcutAction.speedReset: + player.setRate(1.0); + _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); + case ShortcutAction.subSeekNext: + player.command(['sub-seek', '1']); + case ShortcutAction.subSeekPrev: + player.command(['sub-seek', '-1']); + case ShortcutAction.shaderToggle: + onToggleShader?.call(); + case ShortcutAction.skipMarker: + onSkipMarker?.call(); + case ShortcutAction.screenshot: + unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); + case ShortcutAction.zoomIn: + onZoomIn?.call(); + case ShortcutAction.zoomOut: + onZoomOut?.call(); + case ShortcutAction.zoomReset: + onZoomReset?.call(); + } return KeyEventResult.handled; } } @@ -310,106 +354,6 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.ignored; } - void _executeAction( - ShortcutAction action, - Player player, - VoidCallback? onToggleFullscreen, - VoidCallback? onToggleSubtitles, - VoidCallback? onNextAudioTrack, - VoidCallback? onNextSubtitleTrack, - VoidCallback? onNextChapter, - VoidCallback? onPreviousChapter, { - VoidCallback? onPlayPause, - VoidCallback? onToggleShader, - VoidCallback? onSkipMarker, - VoidCallback? onNextEpisode, - VoidCallback? onPreviousEpisode, - VoidCallback? onScreenshot, - VoidCallback? onZoomIn, - VoidCallback? onZoomOut, - VoidCallback? onZoomReset, - VoidCallback? onVolumeUp, - VoidCallback? onVolumeDown, - VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, - ValueChanged? onLiveSeekBy, - Future Function(Duration position)? onSeekRequested, - }) { - void performSeek(int offsetSeconds) { - // Relative live-TV skip: route through the parent accumulator, which - // coalesces a rapid burst into one transcode re-open (#1253). - if (onLiveSeekBy != null) { - onLiveSeekBy(offsetSeconds); - } else { - final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); - unawaited((onSeekRequested ?? player.seek)(target)); - } - } - - switch (action) { - case ShortcutAction.playPause: - (onPlayPause ?? player.playOrPause).call(); - case ShortcutAction.volumeUp: - onVolumeUp?.call(); - case ShortcutAction.volumeDown: - onVolumeDown?.call(); - case ShortcutAction.seekForward: - performSeek(_seekTimeSmall); - case ShortcutAction.seekBackward: - performSeek(-_seekTimeSmall); - case ShortcutAction.seekForwardLarge: - performSeek(_seekTimeLarge); - case ShortcutAction.seekBackwardLarge: - performSeek(-_seekTimeLarge); - case ShortcutAction.fullscreenToggle: - onToggleFullscreen?.call(); - case ShortcutAction.muteToggle: - onToggleMute?.call(); - case ShortcutAction.subtitleToggle: - onToggleSubtitles?.call(); - case ShortcutAction.audioTrackNext: - onNextAudioTrack?.call(); - case ShortcutAction.subtitleTrackNext: - onNextSubtitleTrack?.call(); - case ShortcutAction.chapterNext: - onNextChapter?.call(); - case ShortcutAction.chapterPrevious: - onPreviousChapter?.call(); - case ShortcutAction.episodeNext: - onNextEpisode?.call(); - case ShortcutAction.episodePrevious: - onPreviousEpisode?.call(); - case ShortcutAction.speedIncrease: - final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); - player.setRate(newRateUp); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); - case ShortcutAction.speedDecrease: - final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); - player.setRate(newRateDown); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); - case ShortcutAction.speedReset: - player.setRate(1.0); - _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); - case ShortcutAction.subSeekNext: - player.command(['sub-seek', '1']); - case ShortcutAction.subSeekPrev: - player.command(['sub-seek', '-1']); - case ShortcutAction.shaderToggle: - onToggleShader?.call(); - case ShortcutAction.skipMarker: - onSkipMarker?.call(); - case ShortcutAction.screenshot: - unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); - case ShortcutAction.zoomIn: - onZoomIn?.call(); - case ShortcutAction.zoomOut: - onZoomOut?.call(); - case ShortcutAction.zoomReset: - onZoomReset?.call(); - } - } - String getActionDisplayName(String action) { final shortcut = ShortcutAction.fromId(action); if (shortcut == null) return action; diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index b9ce944a..dcab1efe 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -182,85 +182,32 @@ class ShaderAssetLoader { /// Get the shader file paths for an Anime4K preset. /// Returns a list of shader paths in the correct order for MPV. static Future> getAnime4KShaders(Anime4KConfig config) async { + final (restoreVariant, upscaleVariant) = switch (config.quality) { + Anime4KQuality.fast => ('restore_m', 'upscale_m'), + Anime4KQuality.hq => ('restore_vl', 'upscale_vl'), + }; + + // All modes start with Clamp, then apply their own ordered chain. + final chain = [ + 'clamp', + ...switch (config.mode) { + Anime4KMode.modeA => [restoreVariant], + Anime4KMode.modeB => [restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeC => [upscaleVariant, 'downscale'], + Anime4KMode.modeAA => [restoreVariant, restoreVariant], + Anime4KMode.modeBB => [restoreVariant, restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeCA => [upscaleVariant, restoreVariant, 'downscale'], + }, + ]; + final shaders = []; - final quality = config.quality; - final mode = config.mode; - - String restoreVariant; - String upscaleVariant; - - switch (quality) { - case Anime4KQuality.fast: - restoreVariant = 'restore_m'; - upscaleVariant = 'upscale_m'; - break; - case Anime4KQuality.hq: - restoreVariant = 'restore_vl'; - upscaleVariant = 'upscale_vl'; - break; - } - - // Build shader chain based on mode - // All modes start with Clamp - final clampPath = await _extractShader(_anime4kShaders['clamp']!); - if (clampPath != null) shaders.add(clampPath); - - switch (mode) { - case Anime4KMode.modeA: - // A: Clamp + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - break; - - case Anime4KMode.modeB: - // B: Clamp + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeC: - // C: Clamp + Upscale + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeAA: - // A+A: Clamp + Restore + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - break; - - case Anime4KMode.modeBB: - // B+B: Clamp + Restore + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeCA: - // C+A: Clamp + Upscale + Restore + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; + final extracted = {}; + for (final key in chain) { + if (!extracted.containsKey(key)) { + extracted[key] = await _extractShader(_anime4kShaders[key]!); + } + final shaderPath = extracted[key]; + if (shaderPath != null) shaders.add(shaderPath); } return shaders; diff --git a/lib/services/shortcut_action.dart b/lib/services/shortcut_action.dart index 34f9364e..60ca5fdc 100644 --- a/lib/services/shortcut_action.dart +++ b/lib/services/shortcut_action.dart @@ -9,8 +9,9 @@ import 'shader_service.dart'; /// One row per action carries everything about it except the behaviour: the /// persisted [id], the [defaultHotKey] shipped with the app, the localized /// [label], and the capability flags that gate dispatch. Adding a shortcut is -/// one entry here plus a case in `KeyboardShortcutsService._executeAction`, -/// which the analyzer demands because that switch is exhaustive over this enum. +/// one entry here plus a case in +/// `KeyboardShortcutsService.handleVideoPlayerKeyEvent`, which the analyzer +/// demands because that switch is exhaustive over this enum. /// /// Declaration order is the order shortcuts are listed in settings, and [id] is /// persisted in preferences — do not reorder or rename existing entries. diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart deleted file mode 100644 index 7d0a554b..00000000 --- a/lib/services/video_pip_manager.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import '../mpv/mpv.dart'; -import '../services/pip_service.dart'; -import '../utils/app_logger.dart'; - -class VideoPIPManager { - final Player player; - - /// Current viewport size, used as the PiP aspect ratio fallback. - final Size? Function() playerSize; - - VideoPIPManager({required this.player, required this.playerSize}); - - /// Callback to prepare video filter before entering PiP - VoidCallback? onBeforeEnterPip; - - /// Get current video dimensions (display or storage or fallback to viewport) - Future<(int? width, int? height)> _getVideoDimensions() async { - int? width; - int? height; - - try { - final dwidth = await player.getProperty('dwidth'); - final dheight = await player.getProperty('dheight'); - if (dwidth != null && dheight != null) { - width = int.tryParse(dwidth); - height = int.tryParse(dheight); - } - } catch (e) { - appLogger.d('VideoPipManager: dwidth/dheight unavailable', error: e); - } - - if (width == null || height == null) { - try { - final videoWidth = await player.getProperty('width'); - final videoHeight = await player.getProperty('height'); - if (videoWidth != null && videoHeight != null) { - width = int.tryParse(videoWidth); - height = int.tryParse(videoHeight); - } - } catch (e) { - appLogger.d('VideoPipManager: width/height unavailable', error: e); - } - } - - final viewport = playerSize(); - width ??= viewport?.width.toInt(); - height ??= viewport?.height.toInt(); - - return (width, height); - } - - Future<(bool success, String? error)> togglePIP() async { - final supported = await PipService.isSupported(); - if (!supported) return (false, 'PiP not supported on this device'); - - // If PiP is already active, exit it - if (PipService().isPipActive.value) { - await PipService.exit(); - return (true, null); - } - - // Reset video filter to contain mode before entering PiP. Android, iOS, - // and macOS all reuse the inline video surface/layer for PiP. - if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { - onBeforeEnterPip?.call(); - // Wait a frame for the filter change to take effect - await Future.delayed(const Duration(milliseconds: 50)); - } - - final dims = await _getVideoDimensions(); - return await PipService.enter(width: dims.$1, height: dims.$2); - } - - Future updateAutoPipState({required bool isPlaying}) async { - if (!isPlaying) { - await PipService.setAutoPipReady(ready: false); - return; - } - - final dims = await _getVideoDimensions(); - await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); - } - - /// Disable auto-PiP (called on dispose or when leaving player) - Future disableAutoPip() async { - await PipService.setAutoPipReady(ready: false); - } -} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index cd2f8059..6ce43f00 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1135,53 +1135,29 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final playlistName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'playlist', + item: item, + client: client, + result: result, + createPrompt: ( title: t.playlists.create, - labelText: t.playlists.playlistName, - hintText: t.playlists.enterPlaylistName, - ); - - if (playlistName == null || playlistName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}'); - final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (newPlaylist != null) { - appLogger.d('Successfully created playlist: ${newPlaylist.title}'); - showSuccessSnackBar(context, t.playlists.created); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - } else { - appLogger.e('Failed to create playlist - API returned null'); - showErrorSnackBar(context, t.playlists.errorCreating); - } - } - } else { - appLogger.d('Adding item ${item.id} to playlist $result'); - final success = await client.addToPlaylist(playlistId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to playlist $result'); - showSuccessSnackBar(context, t.playlists.itemAdded); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to playlist $result - API returned false'); - showErrorSnackBar(context, t.playlists.errorAdding); - } - } - } + label: t.playlists.playlistName, + hint: t.playlists.enterPlaylistName, + ), + create: (name) => client.createPlaylist(title: name, items: [item]), + createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}', + eagerSyncId: (_) => null, + add: () => client.addToPlaylist(playlistId: result, items: [item]), + messages: ( + created: t.playlists.created, + createError: t.playlists.errorCreating, + added: t.playlists.itemAdded, + addError: t.playlists.errorAdding, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1239,59 +1215,34 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final collectionName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'collection', + item: item, + client: client, + result: result, + createPrompt: ( title: t.common.createNew, - labelText: t.collections.collectionName, - hintText: t.collections.enterCollectionName, - ); - - if (collectionName == null || collectionName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}'); - final newCollectionId = await client.createCollection( + label: t.collections.collectionName, + hint: t.collections.enterCollectionName, + ), + create: (name) => client.createCollection( libraryId: resolvedLibraryId, - title: collectionName, + title: name, items: [item], itemKind: itemKind, - ); - - if (!context.mounted) return; - - if (context.mounted) { - if (newCollectionId != null) { - appLogger.d('Successfully created collection with ID: $newCollectionId'); - showSuccessSnackBar(context, t.collections.created); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); - } else { - appLogger.e('Failed to create collection - API returned null'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } else { - appLogger.d('Adding item ${item.id} to collection $result'); - final success = await client.addToCollection(collectionId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to collection $result'); - showSuccessSnackBar(context, t.collections.addedToCollection); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to collection $result - API returned false'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } + ), + createdLog: (id) => 'Successfully created collection with ID: $id', + eagerSyncId: (id) => id, + add: () => client.addToCollection(collectionId: result, items: [item]), + messages: ( + created: t.collections.created, + createError: t.collections.errorAddingToCollection, + added: t.collections.addedToCollection, + addError: t.collections.errorAddingToCollection, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1300,6 +1251,70 @@ class MediaContextMenuState extends State { } } + /// Create-or-add tail shared by the "Add to playlist" and "Add to collection" + /// flows. [result] is the picker selection: an existing container id, or the + /// `_create_new` sentinel to prompt for a name and create one via [create]. + /// [eagerSyncId] maps a freshly created container to the id to eager-sync, or + /// `null` to skip it. + Future _addItemToContainer( + BuildContext context, { + required String kind, + required MediaItem item, + required MediaServerClient client, + required String result, + required ({String title, String label, String hint}) createPrompt, + required Future Function(String name) create, + required String Function(T created) createdLog, + required String? Function(T created) eagerSyncId, + required Future Function() add, + required ({String created, String createError, String added, String addError}) messages, + required VoidCallback notifyChanged, + }) async { + if (result == '_create_new') { + final name = await showTextInputDialog( + context, + title: createPrompt.title, + labelText: createPrompt.label, + hintText: createPrompt.hint, + ); + + if (name == null || name.isEmpty || !context.mounted) return; + + appLogger.d('Creating $kind "$name" seeded with item ${item.id}'); + final created = await create(name); + + if (!context.mounted) return; + + if (created != null) { + appLogger.d(createdLog(created)); + showSuccessSnackBar(context, messages.created); + notifyChanged(); + final syncId = eagerSyncId(created); + if (syncId != null) { + _triggerEagerSyncIfRuleExists(context, client.serverId, syncId); + } + } else { + appLogger.e('Failed to create $kind - API returned null'); + showErrorSnackBar(context, messages.createError); + } + } else { + appLogger.d('Adding item ${item.id} to $kind $result'); + final success = await add(); + + if (!context.mounted) return; + + if (success) { + appLogger.d('Successfully added item(s) to $kind $result'); + showSuccessSnackBar(context, messages.added); + notifyChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, result); + } else { + appLogger.e('Failed to add item(s) to $kind $result - API returned false'); + showErrorSnackBar(context, messages.addError); + } + } + } + Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { if (!mounted) return; // Presented from the menu's own context so a screen-level diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index f366dde8..9b3851b3 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -124,8 +124,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { onVolumeUp: () => widget.volumeController.adjust(10), onVolumeDown: () => widget.volumeController.adjust(-10), onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, onLiveSeekBy: widget.onLiveSeekBy, onSeekRequested: widget.onSeekRequested, ); diff --git a/test/services/shader_asset_loader_test.dart b/test/services/shader_asset_loader_test.dart index 1dfa1ffa..0bde6cc6 100644 --- a/test/services/shader_asset_loader_test.dart +++ b/test/services/shader_asset_loader_test.dart @@ -136,6 +136,21 @@ void main() { } }); + test('repeats the restore pass in place for doubled Anime4K modes', () async { + final shaders = await ShaderAssetLoader.getAnime4KShaders( + const Anime4KConfig(quality: Anime4KQuality.fast, mode: Anime4KMode.modeBB), + ); + + expect(shaders.map(path.basename).toList(), [ + 'Anime4K_Clamp_Highlights.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Upscale_CNN_x2_M.glsl', + 'Anime4K_AutoDownscalePre_x2.glsl', + ]); + expect(shaders[1], shaders[2]); + }); + test('nested and non-GLSL names are rejected without touching matching files', () async { final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true); final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl'))