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
+66 -73
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),
);
} 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>[]);
}
});
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 = <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');
+72 -128
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,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<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).
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;
+25 -78
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;
}
// 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 = <String, String?>{};
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;
+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);
}
}