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);
|
return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
|
||||||
}
|
}
|
||||||
if (type == 'int') {
|
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;
|
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 {
|
class _BoolSettingRow extends StatelessWidget {
|
||||||
final SubscriptionSetting setting;
|
final SubscriptionSetting setting;
|
||||||
final Object? currentValue;
|
final Object? currentValue;
|
||||||
@@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
final value = _coerceBool(currentValue);
|
final value = _coerceBool(currentValue);
|
||||||
void toggle() => onChanged(!value);
|
void toggle() => onChanged(!value);
|
||||||
return FocusableWrapper(
|
return FocusableWrapper(
|
||||||
@@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
child: Switch(value: value, onChanged: (v) => onChanged(v)),
|
child: Switch(value: value, onChanged: (v) => onChanged(v)),
|
||||||
@@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: _SettingLabel(label: label, summary: summary),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(value, style: theme.textTheme.bodyMedium),
|
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 SubscriptionSetting setting;
|
||||||
final Object? currentValue;
|
final Object? currentValue;
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
final TextInputType? keyboardType;
|
||||||
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
|
final Object? Function(String) parseValue;
|
||||||
final void Function(Object?) onChanged;
|
final void Function(Object?) onChanged;
|
||||||
|
|
||||||
const _IntSettingRow({
|
const _TextFieldSettingRow({
|
||||||
required this.setting,
|
required this.setting,
|
||||||
required this.currentValue,
|
required this.currentValue,
|
||||||
required this.autofocus,
|
required this.autofocus,
|
||||||
|
required this.parseValue,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
this.keyboardType,
|
||||||
|
this.inputFormatters,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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;
|
late final TextEditingController _controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant _IntSettingRow oldWidget) {
|
void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
final next = widget.currentValue?.toString() ?? '';
|
final next = widget.currentValue?.toString() ?? '';
|
||||||
if (next != _controller.text) _controller.text = next;
|
if (next != _controller.text) _controller.text = next;
|
||||||
@@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
children: [
|
children: [
|
||||||
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium),
|
_SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary),
|
||||||
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),
|
const SizedBox(height: 4),
|
||||||
FocusableTextField(
|
FocusableTextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: widget.keyboardType,
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
|
inputFormatters: widget.inputFormatters,
|
||||||
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
||||||
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
||||||
onChanged: (text) {
|
onChanged: (text) => widget.onChanged(widget.parseValue(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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
||||||
|
|
||||||
if (_autoPipEnabled) {
|
if (_autoPipEnabled) {
|
||||||
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing));
|
unawaited(_updateAutoPipState(isPlaying: currentPlayer.state.playing));
|
||||||
}
|
}
|
||||||
return _MediaReloadOutcome.opened;
|
return _MediaReloadOutcome.opened;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
|||||||
_autoPipEnteringCallback = null;
|
_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.
|
/// Called from both live TV and VOD playback paths.
|
||||||
Future<void> _initVideoFilterAndPip() async {
|
Future<void> _initVideoFilterAndPip() async {
|
||||||
final currentPlayer = player;
|
final currentPlayer = player;
|
||||||
if (!mounted || currentPlayer == null) return;
|
if (!mounted || currentPlayer == null) return;
|
||||||
if (_videoFilterManager != null && _videoPIPManager != null) {
|
if (_videoFilterManager != null && _pipInitialized) {
|
||||||
_attachPipStateListener();
|
_attachPipStateListener();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -44,20 +44,91 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
|||||||
unawaited(_videoFilterManager!.updateVideoFilter());
|
unawaited(_videoFilterManager!.updateVideoFilter());
|
||||||
}
|
}
|
||||||
|
|
||||||
_videoPIPManager ??= VideoPIPManager(
|
_pipInitialized = true;
|
||||||
player: currentPlayer,
|
|
||||||
playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null,
|
|
||||||
);
|
|
||||||
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
|
||||||
_attachPipStateListener();
|
_attachPipStateListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _togglePIPMode() async {
|
Future<void> _togglePIPMode() async {
|
||||||
final result = await _videoPIPManager?.togglePIP();
|
if (!_pipInitialized) return;
|
||||||
if (result != null && !result.$1 && mounted) {
|
|
||||||
_restorePipFiltersAfterExit();
|
final supported = await PipService.isSupported();
|
||||||
showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed);
|
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() {
|
void _preparePipFiltersForEntry() {
|
||||||
@@ -99,7 +170,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
|||||||
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
|
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
|
||||||
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
|
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
|
||||||
|
|
||||||
if (_videoPIPManager == null || _videoFilterManager == null) return;
|
if (!_pipInitialized || _videoFilterManager == null) return;
|
||||||
|
|
||||||
if (isInPip) {
|
if (isInPip) {
|
||||||
_preparePipFiltersForEntry();
|
_preparePipFiltersForEntry();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
|||||||
unawaited(DiscordRPCService.instance.pausePlayback());
|
unawaited(DiscordRPCService.instance.pausePlayback());
|
||||||
unawaited(TraktScrobbleService.instance.pausePlayback());
|
unawaited(TraktScrobbleService.instance.pausePlayback());
|
||||||
if (_autoPipEnabled) {
|
if (_autoPipEnabled) {
|
||||||
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false));
|
unawaited(_updateAutoPipState(isPlaying: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
// End-of-video sleep timer takes precedence over autoplay / next-episode
|
// End-of-video sleep timer takes precedence over autoplay / next-episode
|
||||||
|
|||||||
@@ -267,12 +267,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
_stopLiveTimelineUpdates();
|
_stopLiveTimelineUpdates();
|
||||||
_detachPipStateListener();
|
_detachPipStateListener();
|
||||||
_clearAutoPipEnteringCallback();
|
_clearAutoPipEnteringCallback();
|
||||||
final videoPipManager = _videoPIPManager;
|
final pipInitialized = _pipInitialized;
|
||||||
_videoPIPManager = null;
|
_pipInitialized = false;
|
||||||
if (videoPipManager != null) {
|
if (pipInitialized) {
|
||||||
videoPipManager.onBeforeEnterPip = null;
|
|
||||||
try {
|
try {
|
||||||
await videoPipManager.disableAutoPip();
|
await PipService.setAutoPipReady(ready: false);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: 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
|
// Update auto-PiP readiness
|
||||||
if (_autoPipEnabled) {
|
if (_autoPipEnabled) {
|
||||||
_videoPIPManager?.updateAutoPipState(isPlaying: isPlaying);
|
unawaited(_updateAutoPipState(isPlaying: isPlaying));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -305,9 +305,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
|
|
||||||
_autoPipEnteringCallback = autoPipEnteringCallback;
|
_autoPipEnteringCallback = autoPipEnteringCallback;
|
||||||
PipService.onAutoPipEntering = autoPipEnteringCallback;
|
PipService.onAutoPipEntering = autoPipEnteringCallback;
|
||||||
final pipManager = _videoPIPManager;
|
if (currentPlayer.state.playing) {
|
||||||
if (currentPlayer.state.playing && pipManager != null) {
|
unawaited(_updateAutoPipState(isPlaying: true));
|
||||||
unawaited(pipManager.updateAutoPipState(isPlaying: true));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import '../services/track_manager.dart';
|
|||||||
import '../services/track_selection_service.dart';
|
import '../services/track_selection_service.dart';
|
||||||
import '../services/ambient_lighting_service.dart';
|
import '../services/ambient_lighting_service.dart';
|
||||||
import '../services/video_filter_manager.dart';
|
import '../services/video_filter_manager.dart';
|
||||||
import '../services/video_pip_manager.dart';
|
|
||||||
import '../services/video_volume_controller.dart';
|
import '../services/video_volume_controller.dart';
|
||||||
import '../services/pip_service.dart';
|
import '../services/pip_service.dart';
|
||||||
import '../models/shader_preset.dart';
|
import '../models/shader_preset.dart';
|
||||||
@@ -525,7 +524,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority;
|
({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority;
|
||||||
PlaybackProgressTracker? _progressTracker;
|
PlaybackProgressTracker? _progressTracker;
|
||||||
VideoFilterManager? _videoFilterManager;
|
VideoFilterManager? _videoFilterManager;
|
||||||
VideoPIPManager? _videoPIPManager;
|
bool _pipInitialized = false;
|
||||||
ShaderService? _shaderService;
|
ShaderService? _shaderService;
|
||||||
AmbientLightingService? _ambientLightingService;
|
AmbientLightingService? _ambientLightingService;
|
||||||
bool _fullscreenListenerAttached = false;
|
bool _fullscreenListenerAttached = false;
|
||||||
@@ -1517,11 +1516,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_stopLiveTimelineUpdates();
|
_stopLiveTimelineUpdates();
|
||||||
|
|
||||||
_detachPipStateListener();
|
_detachPipStateListener();
|
||||||
_videoPIPManager?.onBeforeEnterPip = null;
|
if (_pipInitialized) unawaited(PipService.setAutoPipReady(ready: false));
|
||||||
unawaited(_videoPIPManager?.disableAutoPip());
|
|
||||||
_clearAutoPipEnteringCallback();
|
_clearAutoPipEnteringCallback();
|
||||||
_videoFilterManager?.dispose();
|
_videoFilterManager?.dispose();
|
||||||
_videoPIPManager = null;
|
_pipInitialized = false;
|
||||||
_videoFilterManager = null;
|
_videoFilterManager = null;
|
||||||
|
|
||||||
_scrubPreviewSource?.dispose();
|
_scrubPreviewSource?.dispose();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ typedef LibraryAggregationResult = ({
|
|||||||
Set<String> succeededServerIds,
|
Set<String> succeededServerIds,
|
||||||
Set<String> cancelledServerIds,
|
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)
|
/// Whether [error] is a client-side abort (client teardown mid-request)
|
||||||
/// rather than a genuine server failure. Aggregation reports these servers
|
/// 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
|
/// Fetch libraries from all online clients regardless of backend, returning
|
||||||
/// the merged neutral [MediaLibrary]s alongside the ids of the servers whose
|
/// the merged neutral [MediaLibrary]s alongside the ids of the servers whose
|
||||||
/// fetch actually succeeded. [serverIds] restricts the fan-out to those
|
/// fetch actually succeeded. [serverIds] restricts the fan-out to those
|
||||||
@@ -77,24 +109,15 @@ class DataAggregationService {
|
|||||||
cancelledServerIds: const <String>{},
|
cancelledServerIds: const <String>{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final succeededServerIds = <String>{};
|
final fetched = await _fanOut<MediaLibrary>(
|
||||||
final cancelledServerIds = <String>{};
|
clients,
|
||||||
final futures = clients.entries.map((entry) async {
|
failureMessage: (serverId) => 'Failed neutral library fetch from $serverId',
|
||||||
try {
|
fetch: (_, client) => client.fetchLibraries(),
|
||||||
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);
|
|
||||||
return (
|
return (
|
||||||
libraries: [for (final list in results) ...list],
|
libraries: fetched.items,
|
||||||
succeededServerIds: succeededServerIds,
|
succeededServerIds: fetched.succeededServerIds,
|
||||||
cancelledServerIds: cancelledServerIds,
|
cancelledServerIds: fetched.cancelledServerIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,24 +136,12 @@ class DataAggregationService {
|
|||||||
return (items: const <MediaItem>[], succeededServerIds: const <String>{}, cancelledServerIds: const <String>{});
|
return (items: const <MediaItem>[], succeededServerIds: const <String>{}, cancelledServerIds: const <String>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
final cancelledServerIds = <String>{};
|
final fetched = await _fanOut<MediaItem>(
|
||||||
final futures = clients.entries.map((entry) async {
|
clients,
|
||||||
final client = entry.value;
|
failureMessage: (serverId) => 'Failed on-deck fetch from $serverId',
|
||||||
try {
|
fetch: (_, client) => client.fetchContinueWatching(count: limit),
|
||||||
final items = await client.fetchContinueWatching(count: limit);
|
);
|
||||||
return (serverId: entry.key, items: items);
|
final allOnDeck = fetched.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();
|
|
||||||
|
|
||||||
// Filter out items from hidden libraries
|
// Filter out items from hidden libraries
|
||||||
List<MediaItem> filteredOnDeck = allOnDeck;
|
List<MediaItem> filteredOnDeck = allOnDeck;
|
||||||
@@ -154,7 +165,11 @@ class DataAggregationService {
|
|||||||
|
|
||||||
appLogger.i('Fetched ${items.length} on deck items from all servers');
|
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
|
/// Merge an [existing] Continue Watching list with [fresh] rows from
|
||||||
@@ -382,11 +397,10 @@ class DataAggregationService {
|
|||||||
? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries)
|
? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final cancelledServerIds = <String>{};
|
final fetched = await _fanOut<MediaHub>(
|
||||||
final futures = clients.entries.map((entry) async {
|
clients,
|
||||||
final serverId = entry.key;
|
failureMessage: (serverId) => 'Failed to fetch hubs from server $serverId',
|
||||||
final client = entry.value;
|
fetch: (serverId, client) async {
|
||||||
try {
|
|
||||||
final serverLibraries = libraries?[serverId];
|
final serverLibraries = libraries?[serverId];
|
||||||
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
|
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
|
||||||
final hubItemLimit = limit ?? defaultHubPreviewLimit;
|
final hubItemLimit = limit ?? defaultHubPreviewLimit;
|
||||||
@@ -413,28 +427,13 @@ class DataAggregationService {
|
|||||||
includePlaybackHubs: includePlaybackHubs,
|
includePlaybackHubs: includePlaybackHubs,
|
||||||
libraries: useGlobalHubs ? serverLibraries : null,
|
libraries: useGlobalHubs ? serverLibraries : null,
|
||||||
);
|
);
|
||||||
return (
|
return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys);
|
||||||
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>[]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final results = await Future.wait(futures);
|
final all = fetched.items;
|
||||||
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 hubs = limit != null && limit < all.length ? all.sublist(0, limit) : all;
|
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
|
/// Per-library hub fetch for a single client. Filters to visible libraries
|
||||||
@@ -519,18 +518,12 @@ class DataAggregationService {
|
|||||||
final resultLimit = limit ?? defaultMediaSearchLimit;
|
final resultLimit = limit ?? defaultMediaSearchLimit;
|
||||||
final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit;
|
final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit;
|
||||||
|
|
||||||
final futures = clients.entries.map((entry) async {
|
final fetched = await _fanOut<MediaItem>(
|
||||||
final client = entry.value;
|
clients,
|
||||||
try {
|
failureMessage: (serverId) => 'Search failed on $serverId',
|
||||||
return await client.searchItems(query, limit: fetchLimit);
|
fetch: (_, client) => client.searchItems(query, limit: fetchLimit),
|
||||||
} catch (e, st) {
|
);
|
||||||
appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st);
|
final result = rankMediaSearchResults(fetched.items, query, limit: resultLimit);
|
||||||
return <MediaItem>[];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final allResults = (await Future.wait(futures)).expand((l) => l).toList();
|
|
||||||
final result = rankMediaSearchResults(allResults, query, limit: resultLimit);
|
|
||||||
|
|
||||||
appLogger.i('Found ${result.length} search results across all servers');
|
appLogger.i('Found ${result.length} search results across all servers');
|
||||||
|
|
||||||
|
|||||||
@@ -202,8 +202,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
VoidCallback? onVolumeUp,
|
VoidCallback? onVolumeUp,
|
||||||
VoidCallback? onVolumeDown,
|
VoidCallback? onVolumeDown,
|
||||||
VoidCallback? onToggleMute,
|
VoidCallback? onToggleMute,
|
||||||
int? currentPositionEpoch,
|
|
||||||
ValueChanged<int>? onLiveSeek,
|
|
||||||
ValueChanged<int>? onLiveSeekBy,
|
ValueChanged<int>? onLiveSeekBy,
|
||||||
Future<void> Function(Duration position)? onSeekRequested,
|
Future<void> Function(Duration position)? onSeekRequested,
|
||||||
}) {
|
}) {
|
||||||
@@ -277,32 +275,78 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
_executeAction(
|
void performSeek(int offsetSeconds) {
|
||||||
action,
|
// Relative live-TV skip: route through the parent accumulator, which
|
||||||
player,
|
// coalesces a rapid burst into one transcode re-open (#1253).
|
||||||
onToggleFullscreen,
|
if (onLiveSeekBy != null) {
|
||||||
onToggleSubtitles,
|
onLiveSeekBy(offsetSeconds);
|
||||||
onNextAudioTrack,
|
} else {
|
||||||
onNextSubtitleTrack,
|
final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds));
|
||||||
onNextChapter,
|
unawaited((onSeekRequested ?? player.seek)(target));
|
||||||
onPreviousChapter,
|
}
|
||||||
onPlayPause: onPlayPause,
|
}
|
||||||
onToggleShader: onToggleShader,
|
|
||||||
onSkipMarker: onSkipMarker,
|
switch (action) {
|
||||||
onNextEpisode: onNextEpisode,
|
case ShortcutAction.playPause:
|
||||||
onPreviousEpisode: onPreviousEpisode,
|
(onPlayPause ?? player.playOrPause).call();
|
||||||
onScreenshot: onScreenshot,
|
case ShortcutAction.volumeUp:
|
||||||
onZoomIn: onZoomIn,
|
onVolumeUp?.call();
|
||||||
onZoomOut: onZoomOut,
|
case ShortcutAction.volumeDown:
|
||||||
onZoomReset: onZoomReset,
|
onVolumeDown?.call();
|
||||||
onVolumeUp: onVolumeUp,
|
case ShortcutAction.seekForward:
|
||||||
onVolumeDown: onVolumeDown,
|
performSeek(_seekTimeSmall);
|
||||||
onToggleMute: onToggleMute,
|
case ShortcutAction.seekBackward:
|
||||||
currentPositionEpoch: currentPositionEpoch,
|
performSeek(-_seekTimeSmall);
|
||||||
onLiveSeek: onLiveSeek,
|
case ShortcutAction.seekForwardLarge:
|
||||||
onLiveSeekBy: onLiveSeekBy,
|
performSeek(_seekTimeLarge);
|
||||||
onSeekRequested: onSeekRequested,
|
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;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,106 +354,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
return KeyEventResult.ignored;
|
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) {
|
String getActionDisplayName(String action) {
|
||||||
final shortcut = ShortcutAction.fromId(action);
|
final shortcut = ShortcutAction.fromId(action);
|
||||||
if (shortcut == null) return action;
|
if (shortcut == null) return action;
|
||||||
|
|||||||
@@ -182,85 +182,32 @@ class ShaderAssetLoader {
|
|||||||
/// Get the shader file paths for an Anime4K preset.
|
/// Get the shader file paths for an Anime4K preset.
|
||||||
/// Returns a list of shader paths in the correct order for MPV.
|
/// Returns a list of shader paths in the correct order for MPV.
|
||||||
static Future<List<String>> getAnime4KShaders(Anime4KConfig config) async {
|
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 shaders = <String>[];
|
||||||
final quality = config.quality;
|
final extracted = <String, String?>{};
|
||||||
final mode = config.mode;
|
for (final key in chain) {
|
||||||
|
if (!extracted.containsKey(key)) {
|
||||||
String restoreVariant;
|
extracted[key] = await _extractShader(_anime4kShaders[key]!);
|
||||||
String upscaleVariant;
|
}
|
||||||
|
final shaderPath = extracted[key];
|
||||||
switch (quality) {
|
if (shaderPath != null) shaders.add(shaderPath);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return shaders;
|
return shaders;
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import 'shader_service.dart';
|
|||||||
/// One row per action carries everything about it except the behaviour: the
|
/// One row per action carries everything about it except the behaviour: the
|
||||||
/// persisted [id], the [defaultHotKey] shipped with the app, the localized
|
/// persisted [id], the [defaultHotKey] shipped with the app, the localized
|
||||||
/// [label], and the capability flags that gate dispatch. Adding a shortcut is
|
/// [label], and the capability flags that gate dispatch. Adding a shortcut is
|
||||||
/// one entry here plus a case in `KeyboardShortcutsService._executeAction`,
|
/// one entry here plus a case in
|
||||||
/// which the analyzer demands because that switch is exhaustive over this enum.
|
/// `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
|
/// Declaration order is the order shortcuts are listed in settings, and [id] is
|
||||||
/// persisted in preferences — do not reorder or rename existing entries.
|
/// 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 == null || !context.mounted) return;
|
||||||
|
|
||||||
if (result == '_create_new') {
|
await _addItemToContainer<MediaPlaylist>(
|
||||||
final playlistName = await showTextInputDialog(
|
context,
|
||||||
context,
|
kind: 'playlist',
|
||||||
|
item: item,
|
||||||
|
client: client,
|
||||||
|
result: result,
|
||||||
|
createPrompt: (
|
||||||
title: t.playlists.create,
|
title: t.playlists.create,
|
||||||
labelText: t.playlists.playlistName,
|
label: t.playlists.playlistName,
|
||||||
hintText: t.playlists.enterPlaylistName,
|
hint: t.playlists.enterPlaylistName,
|
||||||
);
|
),
|
||||||
|
create: (name) => client.createPlaylist(title: name, items: [item]),
|
||||||
if (playlistName == null || playlistName.isEmpty || !context.mounted) {
|
createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}',
|
||||||
return;
|
eagerSyncId: (_) => null,
|
||||||
}
|
add: () => client.addToPlaylist(playlistId: result, items: [item]),
|
||||||
|
messages: (
|
||||||
appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}');
|
created: t.playlists.created,
|
||||||
final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]);
|
createError: t.playlists.errorCreating,
|
||||||
|
added: t.playlists.itemAdded,
|
||||||
if (!context.mounted) return;
|
addError: t.playlists.errorAdding,
|
||||||
|
),
|
||||||
if (context.mounted) {
|
notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(),
|
||||||
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) {
|
} catch (e, stackTrace) {
|
||||||
appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace);
|
appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -1239,59 +1215,34 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
|
|
||||||
if (result == null || !context.mounted) return;
|
if (result == null || !context.mounted) return;
|
||||||
|
|
||||||
if (result == '_create_new') {
|
await _addItemToContainer<String>(
|
||||||
final collectionName = await showTextInputDialog(
|
context,
|
||||||
context,
|
kind: 'collection',
|
||||||
|
item: item,
|
||||||
|
client: client,
|
||||||
|
result: result,
|
||||||
|
createPrompt: (
|
||||||
title: t.common.createNew,
|
title: t.common.createNew,
|
||||||
labelText: t.collections.collectionName,
|
label: t.collections.collectionName,
|
||||||
hintText: t.collections.enterCollectionName,
|
hint: t.collections.enterCollectionName,
|
||||||
);
|
),
|
||||||
|
create: (name) => client.createCollection(
|
||||||
if (collectionName == null || collectionName.isEmpty || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}');
|
|
||||||
final newCollectionId = await client.createCollection(
|
|
||||||
libraryId: resolvedLibraryId,
|
libraryId: resolvedLibraryId,
|
||||||
title: collectionName,
|
title: name,
|
||||||
items: [item],
|
items: [item],
|
||||||
itemKind: itemKind,
|
itemKind: itemKind,
|
||||||
);
|
),
|
||||||
|
createdLog: (id) => 'Successfully created collection with ID: $id',
|
||||||
if (!context.mounted) return;
|
eagerSyncId: (id) => id,
|
||||||
|
add: () => client.addToCollection(collectionId: result, items: [item]),
|
||||||
if (context.mounted) {
|
messages: (
|
||||||
if (newCollectionId != null) {
|
created: t.collections.created,
|
||||||
appLogger.d('Successfully created collection with ID: $newCollectionId');
|
createError: t.collections.errorAddingToCollection,
|
||||||
showSuccessSnackBar(context, t.collections.created);
|
added: t.collections.addedToCollection,
|
||||||
// Trigger refresh of collections tab
|
addError: t.collections.errorAddingToCollection,
|
||||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
),
|
||||||
_triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId);
|
notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(),
|
||||||
} 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) {
|
} catch (e, stackTrace) {
|
||||||
appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace);
|
appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace);
|
||||||
if (context.mounted) {
|
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 {
|
Future<void> _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// Presented from the menu's own context so a screen-level
|
// Presented from the menu's own context so a screen-level
|
||||||
|
|||||||
@@ -124,8 +124,6 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
onVolumeUp: () => widget.volumeController.adjust(10),
|
onVolumeUp: () => widget.volumeController.adjust(10),
|
||||||
onVolumeDown: () => widget.volumeController.adjust(-10),
|
onVolumeDown: () => widget.volumeController.adjust(-10),
|
||||||
onToggleMute: widget.volumeController.toggleMute,
|
onToggleMute: widget.volumeController.toggleMute,
|
||||||
currentPositionEpoch: widget.currentPositionEpoch,
|
|
||||||
onLiveSeek: widget.onLiveSeek,
|
|
||||||
onLiveSeekBy: widget.onLiveSeekBy,
|
onLiveSeekBy: widget.onLiveSeekBy,
|
||||||
onSeekRequested: widget.onSeekRequested,
|
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 {
|
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 customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true);
|
||||||
final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl'))
|
final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl'))
|
||||||
|
|||||||
Reference in New Issue
Block a user