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.
This commit is contained in:
edde746
2026-07-26 06:09:49 +02:00
parent 7416327d4b
commit 83f4e2a263
15 changed files with 443 additions and 598 deletions
+57 -101
View File
@@ -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<TextInputFormatter>? 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)),
),
],
),
@@ -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) {
+83 -12
View File
@@ -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<void> _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<void> _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<void> _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();
@@ -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
@@ -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));
}
}
@@ -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));
}
}
+3 -5
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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();
+65 -72
View File
@@ -25,6 +25,7 @@ typedef LibraryAggregationResult = ({
Set<String> succeededServerIds,
Set<String> cancelledServerIds,
});
typedef _FanOutResult<T> = ({List<T> items, Set<String> succeededServerIds, Set<String> 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<T>> _fanOut<T>(
Map<String, MediaServerClient> clients, {
required String Function(String serverId) failureMessage,
required Future<List<T>> Function(String serverId, MediaServerClient client) fetch,
}) async {
final cancelledServerIds = <String>{};
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: <T>[]);
}
});
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 <String>{},
);
}
final succeededServerIds = <String>{};
final cancelledServerIds = <String>{};
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 <MediaLibrary>[];
}
});
final results = await Future.wait(futures);
final fetched = await _fanOut<MediaLibrary>(
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 <MediaItem>[], succeededServerIds: const <String>{}, cancelledServerIds: const <String>{});
}
final cancelledServerIds = <String>{};
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: <MediaItem>[]);
}
});
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<MediaItem>(
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<MediaItem> 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 = <String>{};
final futures = clients.entries.map((entry) async {
final serverId = entry.key;
final client = entry.value;
try {
final fetched = await _fanOut<MediaHub>(
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),
return _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: <MediaHub>[]);
}
});
final results = await Future.wait(futures);
final succeededServerIds = {
for (final result in results)
if (result.serverId != null) result.serverId!,
};
final all = <MediaHub>[];
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 <MediaItem>[];
}
});
final allResults = (await Future.wait(futures)).expand((l) => l).toList();
final result = rankMediaSearchResults(allResults, query, limit: resultLimit);
final fetched = await _fanOut<MediaItem>(
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');
+5 -61
View File
@@ -202,8 +202,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
VoidCallback? onVolumeUp,
VoidCallback? onVolumeDown,
VoidCallback? onToggleMute,
int? currentPositionEpoch,
ValueChanged<int>? onLiveSeek,
ValueChanged<int>? onLiveSeekBy,
Future<void> Function(Duration position)? onSeekRequested,
}) {
@@ -277,65 +275,6 @@ 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,
);
return KeyEventResult.handled;
}
}
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<int>? onLiveSeek,
ValueChanged<int>? onLiveSeekBy,
Future<void> 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).
@@ -408,6 +347,11 @@ class KeyboardShortcutsService extends ChangeNotifier {
case ShortcutAction.zoomReset:
onZoomReset?.call();
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
}
String getActionDisplayName(String action) {
+24 -77
View File
@@ -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<List<String>> 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 = <String>[
'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 = <String>[];
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;
final extracted = <String, String?>{};
for (final key in chain) {
if (!extracted.containsKey(key)) {
extracted[key] = await _extractShader(_anime4kShaders[key]!);
}
// 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 shaderPath = extracted[key];
if (shaderPath != null) shaders.add(shaderPath);
}
return shaders;
+3 -2
View File
@@ -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.
-91
View File
@@ -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<void> 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<void> disableAutoPip() async {
await PipService.setAutoPipReady(ready: false);
}
}
+106 -91
View File
@@ -1135,53 +1135,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (result == null || !context.mounted) return;
if (result == '_create_new') {
final playlistName = await showTextInputDialog(
await _addItemToContainer<MediaPlaylist>(
context,
kind: 'playlist',
item: item,
client: client,
result: result,
createPrompt: (
title: t.playlists.create,
labelText: t.playlists.playlistName,
hintText: t.playlists.enterPlaylistName,
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(),
);
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);
}
}
}
} 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<MediaContextMenu> {
if (result == null || !context.mounted) return;
if (result == '_create_new') {
final collectionName = await showTextInputDialog(
await _addItemToContainer<String>(
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,
),
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(),
);
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);
}
}
}
} 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<MediaContextMenu> {
}
}
/// 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<void> _addItemToContainer<T extends Object>(
BuildContext context, {
required String kind,
required MediaItem item,
required MediaServerClient client,
required String result,
required ({String title, String label, String hint}) createPrompt,
required Future<T?> Function(String name) create,
required String Function(T created) createdLog,
required String? Function(T created) eagerSyncId,
required Future<bool> 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<void> _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async {
if (!mounted) return;
// Presented from the menu's own context so a screen-level
@@ -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,
);
@@ -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'))