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:
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1135,53 +1135,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (result == '_create_new') {
|
||||
final playlistName = await showTextInputDialog(
|
||||
context,
|
||||
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,
|
||||
);
|
||||
|
||||
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<MediaContextMenu> {
|
||||
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
if (result == '_create_new') {
|
||||
final collectionName = await showTextInputDialog(
|
||||
context,
|
||||
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,
|
||||
);
|
||||
|
||||
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<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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user