Files
plezy/lib/widgets/video_controls/sheets/video_settings_sheet.dart
T
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00

1482 lines
55 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import '../../../models/shader_preset.dart';
import '../../../media/playback_rate.dart';
import '../../../mpv/mpv.dart';
import '../../../mpv/player/player_native.dart';
import '../../../providers/shader_provider.dart';
import '../../../services/file_picker_service.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../services/video_filter_manager.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../utils/dialogs.dart';
import '../../../utils/app_logger.dart';
import '../../../utils/formatters.dart';
import '../../../utils/platform_detector.dart';
import '../../../utils/quality_preset_labels.dart';
import '../../../utils/latest_async_write.dart';
import '../../../utils/snackbar_helper.dart';
import '../../../theme/mono_tokens.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import '../models/track_controls_state.dart';
import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'version_quality_sheet.dart';
enum _SettingsView {
menu,
speed,
zoom,
versionQuality,
sleep,
audioSync,
subtitleSync,
audioDevice,
shader,
dvConversion,
hdrToneMapping,
}
class _SettingsMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String valueText;
final VoidCallback onTap;
final bool isHighlighted;
final bool allowValueOverflow;
const _SettingsMenuItem({
required this.icon,
required this.title,
required this.valueText,
required this.onTap,
this.isHighlighted = false,
this.allowValueOverflow = false,
});
@override
Widget build(BuildContext context) {
final t = tokens(context);
final valueWidget = Text(
valueText,
style: TextStyle(color: isHighlighted ? Colors.amber : t.textMuted, fontSize: 14),
overflow: allowValueOverflow ? TextOverflow.ellipsis : null,
);
return FocusableListTile(
leading: AppIcon(icon, fill: 1, color: isHighlighted ? Colors.amber : t.textMuted),
title: Text(title),
trailing: Row(
mainAxisSize: .min,
children: [
if (allowValueOverflow) Flexible(child: valueWidget) else valueWidget,
const SizedBox(width: 8),
AppIcon(Symbols.chevron_right_rounded, fill: 1, color: t.textMuted),
],
),
onTap: onTap,
);
}
}
/// Ordering for the sheet's asynchronous pref writes, keyed on the pref key.
///
/// Shared by the toggle rows and the tone-mapping picker rather than owned by
/// either. A pick closes the sheet, so anything scoped to a widget cannot rank
/// a write against one started by a *later* sheet - which is exactly the race
/// here, since reopening and picking again is one tap. Keys are distinct per
/// pref and [LatestAsyncWrite] keeps a generation and tail per key, so the two
/// users never rank against each other.
final LatestAsyncWrite<String> _prefWrites = LatestAsyncWrite<String>();
/// Moves a setting on the device, records it, and puts both halves back when the
/// recording fails.
///
/// Device first is a decision, not an accident: a refusal is a validation. A
/// rejected `hdr-enabled` is how the plane reports that this session can never
/// carry HDR, so nothing may be recorded for a value the device would not take.
/// The price is a window in which the device leads the store, and closing that
/// window is what [_undoSettingWrite] is for - without it a rejected storage
/// write leaves the plane on the newly chosen policy while the switch and the
/// stored preference both still name the old one, for the rest of the session,
/// until the next player initialisation happens to replay the stored value.
///
/// Call this inside [LatestAsyncWrite.commitIfLatest] so a newer intent for the
/// same key cannot land between the failed write and the undo.
///
/// Rethrows whichever half failed, carrying its own stack: the caller owns what
/// the user sees, and for HDR that is a specific message about a surface no
/// retry can fix.
Future<void> _applyThenPersist<T>(Pref<T> pref, T value, FutureOr<void> Function(T value)? apply) async {
if (apply == null) return SettingsService.instance.write(pref, value);
// Read before the store is touched: SharedPreferencesWithCache moves its
// in-process copy ahead of the platform write it may then fail, so asking
// afterwards would answer with the value that did not persist.
final restore = SettingsService.instance.read(pref);
await apply(value);
try {
await SettingsService.instance.write(pref, value);
} catch (error, stackTrace) {
await _undoSettingWrite(pref, restore, apply);
Error.throwWithStackTrace(error, stackTrace);
}
}
/// Puts the store and the device back on [restore] after the store refused a
/// value the device had already taken.
///
/// Each half is attempted independently, the preference first: it is what the
/// next player initialisation replays into the device, so getting it right
/// salvages the session even when the device half then fails too. Failures are
/// logged and swallowed because the caller is already rethrowing the refusal
/// that started this, which is the one carrying a cause worth reporting.
Future<void> _undoSettingWrite<T>(Pref<T> pref, T restore, FutureOr<void> Function(T value) apply) async {
try {
// Rewriting the value already in the store looks redundant and is not: the
// refused write moved SharedPreferencesWithCache's in-process copy before
// the platform call it failed, and that copy is what SettingsService.read
// answers with for the rest of the session. It is moved back here before
// the platform call too, so it is repaired even if this write is refused
// as well.
await SettingsService.instance.write(pref, restore);
} catch (error, stackTrace) {
appLogger.w('Failed to restore the stored "${pref.key}"', error: error, stackTrace: stackTrace);
}
try {
await apply(restore);
} catch (error, stackTrace) {
appLogger.w('Failed to restore "${pref.key}" on the device', error: error, stackTrace: stackTrace);
}
}
class _SettingsToggleItem extends StatefulWidget {
final Pref<bool> pref;
final IconData icon;
final String title;
final FutureOr<void> Function(bool value)? onAfterWrite;
const _SettingsToggleItem({required this.pref, required this.icon, required this.title, this.onAfterWrite});
@override
State<_SettingsToggleItem> createState() => _SettingsToggleItemState();
}
class _SettingsToggleItemState extends State<_SettingsToggleItem> {
bool? _pendingValue;
int _writeGeneration = 0;
@override
void didUpdateWidget(covariant _SettingsToggleItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pref != widget.pref) {
++_writeGeneration;
_pendingValue = null;
}
}
@override
void dispose() {
++_writeGeneration;
super.dispose();
}
void _write(bool next) {
final pref = widget.pref;
final callback = widget.onAfterWrite;
final generation = ++_writeGeneration;
final writeToken = _prefWrites.begin(pref.key);
setState(() {
_pendingValue = next;
});
unawaited(_commitWrite(pref, callback, next, generation, writeToken));
}
Future<void> _commitWrite(
Pref<bool> pref,
FutureOr<void> Function(bool value)? callback,
bool next,
int generation,
int writeToken,
) async {
try {
final committed = await _prefWrites.commitIfLatest(
pref.key,
writeToken,
() => _applyThenPersist(pref, next, callback),
);
if (!committed || !mounted || generation != _writeGeneration) return;
setState(() {
_pendingValue = null;
});
} catch (error, stackTrace) {
appLogger.w('Failed to update playback setting', error: error, stackTrace: stackTrace);
if (!mounted || generation != _writeGeneration) return;
setState(() {
_pendingValue = null;
});
}
}
@override
Widget build(BuildContext context) {
final settings = SettingsService.instance;
return ValueListenableBuilder<bool>(
valueListenable: settings.listenable(widget.pref),
builder: (context, value, _) {
final displayedValue = _pendingValue ?? value;
final isPending = _pendingValue != null;
return FocusableListTile(
leading: AppIcon(widget.icon, fill: 1, color: displayedValue ? Colors.amber : tokens(context).textMuted),
title: Text(widget.title),
trailing: Switch(value: displayedValue, onChanged: isPending ? null : _write, activeThumbColor: Colors.amber),
onTap: isPending ? null : () => _write(!displayedValue),
);
},
);
}
}
/// Reflects the system's resolved audio rendering mode, as the Dolby
/// application guide requires. Renders nothing until the system reports a
/// conclusive value: Apple only resolves `renderingMode` for CarPlay and
/// AirPlay routes, and showing "Stereo" for an inconclusive HDMI route would
/// be worse than showing nothing.
class _AudioRenderingModeItem extends StatefulWidget {
const _AudioRenderingModeItem({required this.player});
final Player player;
@override
State<_AudioRenderingModeItem> createState() => _AudioRenderingModeItemState();
}
class _AudioRenderingModeItemState extends State<_AudioRenderingModeItem> {
AudioRenderingMode? _mode;
Timer? _poll;
@override
void initState() {
super.initState();
unawaited(_refresh());
_poll = Timer.periodic(const Duration(seconds: 2), (_) => unawaited(_refresh()));
}
@override
void dispose() {
_poll?.cancel();
super.dispose();
}
Future<void> _refresh() async {
final mode = await widget.player.getAudioRenderingMode();
if (!mounted) return;
setState(() => _mode = mode);
}
@override
Widget build(BuildContext context) {
final mode = _mode;
if (mode == null || !mode.isConclusive) return const SizedBox.shrink();
final label = switch (mode.rawValue) {
AudioRenderingMode.dolbyAtmos => t.videoSettings.audioOutputDolbyAtmos,
AudioRenderingMode.dolbyAudio => t.videoSettings.audioOutputDolbyAudio,
AudioRenderingMode.surround => t.videoSettings.audioOutputSurround,
AudioRenderingMode.spatialAudio => t.videoSettings.audioOutputSpatial,
_ => t.videoSettings.audioOutputStereo,
};
final highlighted = mode.isDolbyAtmos || mode.isDolbyAudio;
return FocusableListTile(
leading: AppIcon(
Symbols.spatial_audio_rounded,
fill: 1,
color: highlighted ? Colors.amber : tokens(context).textMuted,
),
title: Text(t.videoSettings.audioOutput),
trailing: Text(label, style: TextStyle(color: tokens(context).textMuted)),
);
}
}
/// Unified settings sheet for playback adjustments with in-sheet navigation
class VideoSettingsSheet extends StatefulWidget {
final Player player;
/// Whether this player surface supports Plezy's HDR control.
///
/// Defaults to the native platform capability, but can be supplied by
/// embedders whose capability is known independently of the host platform.
final bool? supportsHdrControl;
/// Shared player-control state. Every playback value and callback this sheet
/// shows (sync offsets, zoom, versions/quality, shaders, ambient lighting,
/// auto-hide) is read straight off it.
final TrackControlsState trackControlsState;
const VideoSettingsSheet({
super.key,
required this.player,
this.supportsHdrControl,
required this.trackControlsState,
});
@override
State<VideoSettingsSheet> createState() => _VideoSettingsSheetState();
}
class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_SettingsView _currentView = _SettingsView.menu;
late int _audioSyncOffset;
late int _subtitleSyncOffset;
late double _zoomScale;
String _dvConversionMode = 'auto';
int _dvConversionWriteGeneration = 0;
// Linux only, and answered by the native side. Starts false so the toggle
// never flashes into view on an output that cannot carry HDR.
bool _linuxHdrSupported = false;
// Re-probes that answer when the app is shown or resumed. Null wherever the
// capability is a constant and there is nothing to re-probe.
AppLifecycleListener? _hdrSupportLifecycle;
// The lifecycle hooks miss the case that matters most here: dragging the
// window to another monitor changes the answer without the app ever being
// hidden. Only the plane sees that, so it says so.
StreamSubscription<void>? _hdrOutputChanged;
late HdrToneMapping _hdrToneMapping;
TrackControlsState get _state => widget.trackControlsState;
// An explicit value from the caller wins. Otherwise the capability is static
// per platform, except on the Linux video plane where it depends on the
// compositor, the output and the plane's bit depth, so it has to be asked for.
bool get _supportsHdrControl =>
widget.supportsHdrControl ??
(_probesHdrSupport ? _linuxHdrSupported : Platform.isIOS || Platform.isMacOS || Platform.isWindows);
// The Linux plane is the only path whose answer moves, and an explicit value
// from the caller replaces the question altogether. Asked through
// PlayerNative.usesLinuxVideoPlane, not Platform.isLinux, so the probe and the
// tone-mapping row it gates resolve the same way under the test override.
bool get _probesHdrSupport => PlayerNative.usesLinuxVideoPlane && widget.supportsHdrControl == null;
bool get _showDebugDvConversionMode {
if (!kDebugMode) return false;
if (Platform.isAndroid) return widget.player.playerType == 'exoplayer';
return (Platform.isIOS || Platform.isMacOS) && widget.player.playerType == 'mpv';
}
@override
void initState() {
super.initState();
_audioSyncOffset = _state.audioSyncOffset;
_subtitleSyncOffset = _state.subtitleSyncOffset;
_zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale);
_hdrToneMapping = SettingsService.instance.read(SettingsService.hdrToneMapping);
_loadDebugDvConversionMode();
if (_probesHdrSupport) {
_hdrSupportLifecycle = AppLifecycleListener(onResume: _refreshLinuxHdrSupport, onShow: _refreshLinuxHdrSupport);
_hdrOutputChanged = widget.player.streams.hdrOutputChanged.listen((_) => _refreshLinuxHdrSupport());
}
_refreshLinuxHdrSupport();
}
@override
void dispose() {
_hdrSupportLifecycle?.dispose();
_hdrOutputChanged?.cancel();
super.dispose();
}
@override
void didUpdateWidget(covariant VideoSettingsSheet oldWidget) {
super.didUpdateWidget(oldWidget);
final nextZoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale);
if (_zoomScale != nextZoomScale) {
_zoomScale = nextZoomScale;
}
}
Future<void> _loadDebugDvConversionMode() async {
if (!_showDebugDvConversionMode) return;
final dvConversionMode = await widget.player.getProperty('dv-conversion-mode');
if (!mounted) return;
setState(() {
_dvConversionMode = _normalizeDvConversionMode(dvConversionMode);
});
}
// Asked again rather than cached for the sheet's lifetime. The native answer
// is "this client can describe HDR *and* the output is in HDR right now", and
// the second half moves when the window changes monitor or the display's HDR
// mode is switched under us. The plane handles that internally and sends
// nothing to Dart, so this rides the hooks that do exist: the app being shown
// or resumed, and the menu coming back into view.
Future<void> _refreshLinuxHdrSupport() async {
if (!_probesHdrSupport) return;
final player = widget.player;
final supported = await player.isHdrOutputSupported();
if (!mounted || player != widget.player || supported == _linuxHdrSupported) return;
setState(() {
_linuxHdrSupported = supported;
});
}
// What the native side answers when the plane can never carry HDR - an 8-bit
// EGL config, or a compositor without the colour-management protocol. Fixed
// for the session, unlike a merely-SDR output, which is now accepted and
// honoured once an HDR output is reached.
static const String _hdrUnsupportedCode = 'HDR_UNSUPPORTED';
// Rethrown so the switch still springs back. The message is what keeps that
// from reading as a lost tap: retrying cannot help for the rest of the session.
Future<void> _setHdrEnabled(bool enabled) async {
try {
await widget.player.setProperty('hdr-enabled', enabled ? 'yes' : 'no');
} on PlatformException catch (error) {
if (mounted && error.code == _hdrUnsupportedCode) showErrorSnackBar(context, t.videoSettings.hdrUnsupported);
rethrow;
}
}
void _setHdrToneMapping(HdrToneMapping mode) {
final targetPlayer = widget.player;
// The tiles stay tappable until close() runs, so two picks can be in flight.
// The native side happens to queue HDR transactions in order, but that is not
// an invariant this file can see: last write wins locally instead.
//
// Deliberately not gated on `mounted`: dismissing the sheet mid-write would
// otherwise leave mpv in the new mode while the stored setting still named
// the old one, and the next playback start would push the old one back.
// A superseded pick can still persist transiently - the staleness check is
// before the body, not inside it - but the winner is serialized behind it
// and overwrites it, so the settled value is the last pick.
final key = SettingsService.hdrToneMapping.key;
final writeToken = _prefWrites.begin(key);
unawaited(() async {
try {
final committed = await _prefWrites.commitIfLatest(
key,
writeToken,
() => _applyThenPersist(
SettingsService.hdrToneMapping,
mode,
(value) => targetPlayer.setProperty('hdr-tone-mapping', value.name),
),
);
if (!committed || !mounted || targetPlayer != widget.player) return;
setState(() {
_hdrToneMapping = mode;
});
OverlaySheetController.of(context).close();
} catch (error, stackTrace) {
appLogger.w('Failed to set the HDR tone-mapping mode', error: error, stackTrace: stackTrace);
// The tick stays on the old mode by design (the stored setting was
// left alone), but a dead tap needs saying so - the HDR toggle's
// refusal shows a snackbar, and this is the same shape of refusal.
if (mounted) showErrorSnackBar(context, t.videoSettings.hdrToneMappingFailed);
}
}());
}
void _setDebugDvConversionMode(String mode) {
final targetPlayer = widget.player;
final generation = ++_dvConversionWriteGeneration;
unawaited(() async {
try {
await targetPlayer.setProperty('dv-conversion-mode', mode);
if (!mounted || generation != _dvConversionWriteGeneration || targetPlayer != widget.player) return;
setState(() {
_dvConversionMode = mode;
});
OverlaySheetController.of(context).close();
} catch (error, stackTrace) {
appLogger.w('Failed to update Dolby Vision conversion mode', error: error, stackTrace: stackTrace);
}
}());
}
void _navigateTo(_SettingsView view) {
// Sync views open as a compact top bar instead of a sub-view
if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) {
_openSyncBar(view);
return;
}
setState(() {
_currentView = view;
});
OverlaySheetController.maybeOf(context)?.refocus();
}
void _openSyncBar(_SettingsView view) {
final controller = OverlaySheetController.maybeOf(context);
if (controller == null) return;
final isSubtitle = view == _SettingsView.subtitleSync;
final title = isSubtitle ? t.videoSettings.subtitleSync : t.videoSettings.audioSync;
final icon = isSubtitle ? Symbols.subtitles_rounded : Symbols.sync_rounded;
final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
// Created here so it can be passed as the overlay's initial focus target.
// The creator disposes it after the overlay's lifecycle completes.
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
// show() with new alignment replaces the current sheet (completing the
// settings sheet future, which restarts the auto-hide timer via
// whenComplete in track_chapter_controls). Cancel it again here.
controller
.show(
alignment: .topCenter,
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
initialFocusNode: sliderFocusNode,
builder: (_) => _CompactSyncBar(
title: title,
icon: icon,
player: widget.player,
propertyName: propertyName,
initialOffset: initialOffset,
sliderFocusNode: sliderFocusNode,
onOffsetChanged: (offset) async {
final settings = SettingsService.instance;
if (isSubtitle) {
await settings.write(SettingsService.subtitleSyncOffset, offset);
} else {
await settings.write(SettingsService.audioSyncOffset, offset);
}
_state.onSyncOffsetChanged?.call(propertyName, offset);
},
),
)
.whenComplete(() {
sliderFocusNode.dispose();
_state.onStartAutoHide?.call();
});
// Cancel auto-hide after show() — the previous sheet's whenComplete
// fires as a microtask and restarts the timer, so schedule our cancel
// to run after that microtask.
Future.microtask(() => _state.onCancelAutoHide?.call());
}
void _navigateBack() {
setState(() {
_currentView = _SettingsView.menu;
});
// The HDR rows live on the menu only, so this is the moment a stale answer
// becomes visible again.
_refreshLinuxHdrSupport();
OverlaySheetController.maybeOf(context)?.refocus();
}
String _getTitle() {
switch (_currentView) {
case _SettingsView.menu:
return t.videoControls.settingsButton;
case _SettingsView.speed:
return t.videoSettings.playbackSpeed;
case _SettingsView.zoom:
return t.videoSettings.zoom;
case _SettingsView.versionQuality:
return _versionQualityTitle();
case _SettingsView.sleep:
return t.videoSettings.sleepTimer;
case _SettingsView.audioSync:
return t.videoSettings.audioSync;
case _SettingsView.subtitleSync:
return t.videoSettings.subtitleSync;
case _SettingsView.audioDevice:
return t.videoSettings.audioOutput;
case _SettingsView.shader:
return t.shaders.title;
case _SettingsView.dvConversion:
return 'DV Conversion Mode';
case _SettingsView.hdrToneMapping:
return t.videoSettings.hdrToneMapping;
}
}
IconData _getIcon() {
switch (_currentView) {
case _SettingsView.menu:
return Symbols.tune_rounded;
case _SettingsView.speed:
return Symbols.speed_rounded;
case _SettingsView.zoom:
return Symbols.zoom_in_rounded;
case _SettingsView.versionQuality:
return Symbols.art_track_rounded;
case _SettingsView.sleep:
return Symbols.bedtime_rounded;
case _SettingsView.audioSync:
return Symbols.sync_rounded;
case _SettingsView.subtitleSync:
return Symbols.subtitles_rounded;
case _SettingsView.audioDevice:
return Symbols.speaker_rounded;
case _SettingsView.shader:
return Symbols.auto_fix_high_rounded;
case _SettingsView.dvConversion:
return Symbols.hdr_strong_rounded;
case _SettingsView.hdrToneMapping:
return Symbols.tonality_rounded;
}
}
String _normalizeDvConversionMode(String? mode) {
return switch (mode?.trim().toLowerCase()) {
'disabled' || 'native' => 'disabled',
'dv81' || 'p8' || 'p7_to_p8' || 'p7-to-p8' => 'dv81',
'hevc' || 'hevc_strip' || 'p7_to_hevc' || 'p7-to-hevc' => 'hevc_strip',
_ => 'auto',
};
}
String _formatDvConversionMode(String mode) {
return switch (_normalizeDvConversionMode(mode)) {
'disabled' => t.settings.dvConversionNative,
'dv81' => t.settings.dvConversionDv81,
'hevc_strip' => t.settings.dvConversionHevcStrip,
_ => t.settings.dvConversionAuto,
};
}
String _formatHdrToneMapping(HdrToneMapping mode) => switch (mode) {
HdrToneMapping.compositor => t.videoSettings.hdrToneMappingCompositor,
HdrToneMapping.player => t.videoSettings.hdrToneMappingPlayer,
};
String _formatSleepTimer(SleepTimerService sleepTimer) {
if (!sleepTimer.isActive) return t.common.off;
final remaining = sleepTimer.remainingTime;
if (remaining == null) return t.common.off;
return t.videoSettings.sleepTimerActive(duration: formatDurationWithSeconds(remaining));
}
String _formatZoomScale(double scale) => '${(scale * 100).round()}%';
void _setZoomScale(double scale) {
final next = VideoFilterManager.normalizeZoomScale(scale);
setState(() {
_zoomScale = next;
});
_state.onVideoZoomChanged?.call(next);
}
void _resetZoomScale() {
setState(() {
_zoomScale = 1.0;
});
final reset = _state.onResetVideoZoom;
if (reset != null) {
reset();
} else {
_state.onVideoZoomChanged?.call(1.0);
}
}
bool get _hasVersionQuality {
return (_state.availableVersions.length > 1 || _state.serverSupportsTranscoding) &&
(_state.onSwitchVersion != null || _state.onSwitchQualityPreset != null);
}
String _versionQualityTitle() {
return versionQualityPickerTitle(
showVersions: _state.availableVersions.length > 1,
showQuality: _state.serverSupportsTranscoding,
);
}
String _versionQualityValueText() {
final values = <String>[];
if (_state.availableVersions.length > 1) values.add(_selectedVersionLabel());
if (_state.serverSupportsTranscoding) values.add(qualityPresetLabel(_state.selectedQualityPreset));
return values.join(' / ');
}
String _selectedVersionLabel() {
final index = _state.selectedMediaIndex;
if (index >= 0 && index < _state.availableVersions.length) {
return _state.availableVersions[index].displayLabel;
}
return t.videoControls.versionColumnHeader;
}
Widget _buildMenuView() {
final sleepTimer = SleepTimerService();
final isDesktop = PlatformDetector.isDesktop(context);
return ListView(
shrinkWrap: true,
children: [
// Playback Speed - hidden for live TV and when user cannot control playback
if (_state.canControl && !_state.isLive)
StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
return _SettingsMenuItem(
icon: Symbols.speed_rounded,
title: t.videoSettings.playbackSpeed,
valueText: formatPlaybackRate(currentRate, normalAtOne: true),
onTap: () => _navigateTo(_SettingsView.speed),
);
},
),
if (_state.onVideoZoomChanged != null || _state.onResetVideoZoom != null)
_SettingsMenuItem(
icon: Symbols.zoom_in_rounded,
title: t.videoSettings.zoom,
valueText: _formatZoomScale(_zoomScale),
isHighlighted: (_zoomScale - 1.0).abs() > 0.0001,
onTap: () => _navigateTo(_SettingsView.zoom),
),
if (_hasVersionQuality)
_SettingsMenuItem(
icon: Symbols.art_track_rounded,
title: _versionQualityTitle(),
valueText: _versionQualityValueText(),
allowValueOverflow: true,
onTap: () => _navigateTo(_SettingsView.versionQuality),
),
// Sleep Timer
ListenableBuilder(
listenable: sleepTimer,
builder: (context, _) {
final isActive = sleepTimer.isActive;
return _SettingsMenuItem(
icon: Symbols.bedtime_rounded,
title: t.videoSettings.sleepTimer,
valueText: _formatSleepTimer(sleepTimer),
isHighlighted: isActive,
onTap: () => _navigateTo(_SettingsView.sleep),
);
},
),
// Audio Sync
_SettingsMenuItem(
icon: Symbols.sync_rounded,
title: t.videoSettings.audioSync,
valueText: formatSyncOffset(_audioSyncOffset.toDouble()),
isHighlighted: _audioSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.audioSync),
),
// Subtitle Sync
_SettingsMenuItem(
icon: Symbols.subtitles_rounded,
title: t.videoSettings.subtitleSync,
valueText: formatSyncOffset(_subtitleSyncOffset.toDouble()),
isHighlighted: _subtitleSyncOffset != 0,
onTap: () => _navigateTo(_SettingsView.subtitleSync),
),
// HDR Toggle
if (_supportsHdrControl)
_SettingsToggleItem(
pref: SettingsService.enableHDR,
icon: Symbols.hdr_strong_rounded,
title: t.videoSettings.hdr,
onAfterWrite: _setHdrEnabled,
),
// Only meaningful where the plane can actually carry HDR, and only the
// Linux plane lets us pick the curve: elsewhere the platform decides who
// tone-maps.
if (_supportsHdrControl && PlayerNative.usesLinuxVideoPlane)
_SettingsMenuItem(
icon: Symbols.tonality_rounded,
title: t.videoSettings.hdrToneMapping,
valueText: _formatHdrToneMapping(_hdrToneMapping),
onTap: () => _navigateTo(_SettingsView.hdrToneMapping),
),
// Auto-Play Next Episode Toggle
_SettingsToggleItem(
pref: SettingsService.autoPlayNextEpisode,
icon: Symbols.skip_next_rounded,
title: t.videoControls.autoPlayNext,
),
// Audio Output Device (Desktop only)
if (isDesktop)
StreamBuilder<AudioDevice>(
stream: widget.player.streams.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, snapshot) {
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
final deviceLabel = currentDevice.description.isEmpty ? currentDevice.name : currentDevice.description;
return _SettingsMenuItem(
icon: Symbols.speaker_rounded,
title: t.videoSettings.audioOutput,
valueText: deviceLabel,
allowValueOverflow: true,
onTap: () => _navigateTo(_SettingsView.audioDevice),
);
},
),
// Audio Passthrough is not here: it configures the audio output route rather
// than this playback, and applying it mid-stream bounces the audio renderer and
// re-decides video tunneling. It lives in Settings > Video Playback next to
// Tunneled Playback, which is applied the same way — at the next player start.
// Dolby playback badge. The Dolby application guide requires the app
// to reflect AVAudioSession.renderingMode; Apple only resolves that
// for CarPlay/AirPlay routes, so it is hidden rather than shown as
// "not Dolby" when the system reports notApplicable.
if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player),
// Audio Normalization
_SettingsToggleItem(
pref: SettingsService.audioNormalization,
icon: Symbols.graphic_eq_rounded,
title: t.videoSettings.audioNormalization,
onAfterWrite: widget.player.setAudioNormalization,
),
// Stereo Downmix
_SettingsToggleItem(
pref: SettingsService.audioDownmix,
icon: Symbols.headphones_rounded,
title: t.videoSettings.audioDownmix,
onAfterWrite: (enabled) => widget.player.setAudioDownmix(
enabled: enabled,
centerBoostDb: SettingsService.instance.read(SettingsService.downmixCenterBoost),
normalize: SettingsService.instance.read(SettingsService.audioDownmixNormalize),
),
),
// Shader Preset (MPV only)
if (_state.shaderService != null && _state.shaderService!.isSupported)
_SettingsMenuItem(
icon: Symbols.auto_fix_high_rounded,
title: t.shaders.title,
valueText: _state.shaderService!.currentPreset.id == ShaderPreset.none.id
? t.common.off
: _state.shaderService!.currentPreset.name,
isHighlighted: _state.shaderService!.currentPreset.isEnabled,
onTap: () => _navigateTo(_SettingsView.shader),
),
// Ambient Lighting (MPV only)
if (_state.onToggleAmbientLighting != null)
FocusableListTile(
leading: AppIcon(
Symbols.blur_on_rounded,
fill: 1,
color: _state.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted,
),
title: Text(t.videoControls.ambientLighting),
trailing: Switch(
value: _state.isAmbientLightingEnabled,
onChanged: (_) {
_state.onToggleAmbientLighting?.call();
OverlaySheetController.of(context).close();
},
activeThumbColor: Colors.amber,
),
onTap: () {
_state.onToggleAmbientLighting?.call();
OverlaySheetController.of(context).close();
},
),
// Performance Overlay Toggle
_SettingsToggleItem(
pref: SettingsService.showPerformanceOverlay,
icon: Symbols.analytics_rounded,
title: t.videoSettings.performanceOverlay,
),
if (_showDebugDvConversionMode)
_SettingsMenuItem(
icon: Symbols.hdr_strong_rounded,
title: t.settings.dvConversionMode,
valueText: _formatDvConversionMode(_dvConversionMode),
isHighlighted: _dvConversionMode != 'auto',
onTap: () => _navigateTo(_SettingsView.dvConversion),
),
// Debug: Trigger MPV Fallback (Android ExoPlayer only)
if (kDebugMode && Platform.isAndroid && widget.player.playerType == 'exoplayer')
FocusableListTile(
leading: AppIcon(Symbols.swap_horiz_rounded, fill: 1, color: tokens(context).textMuted),
title: const Text('Trigger MPV Fallback'),
onTap: () {
const MethodChannel('com.plezy/exo_player').invokeMethod('triggerFallback');
OverlaySheetController.of(context).close();
},
),
if (kDebugMode)
for (final status in const [500, 404, 503])
FocusableListTile(
leading: AppIcon(Symbols.bug_report_rounded, fill: 1, color: tokens(context).textMuted),
title: Text('Simulate HTTP $status from server'),
onTap: () {
final player = widget.player;
OverlaySheetController.of(context).close();
if (player is PlayerBase) {
player.debugSimulateServerHttpError(status);
}
},
),
],
);
}
Widget _buildDvConversionView() {
final modes = [
(value: 'auto', title: t.settings.dvConversionAuto, subtitle: t.settings.dvConversionAutoDescription),
(value: 'disabled', title: t.settings.dvConversionNative, subtitle: t.settings.dvConversionNativeDescription),
(value: 'dv81', title: t.settings.dvConversionDv81, subtitle: t.settings.dvConversionDv81Description),
(
value: 'hevc_strip',
title: t.settings.dvConversionHevcStrip,
subtitle: t.settings.dvConversionHevcStripDescription,
),
];
final primary = Theme.of(context).colorScheme.primary;
return ListView(
shrinkWrap: true,
children: [
for (final mode in modes)
FocusableListTile(
title: Text(mode.title, style: TextStyle(color: _dvConversionMode == mode.value ? primary : null)),
subtitle: Text(mode.subtitle, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)),
trailing: _dvConversionMode == mode.value ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
onTap: () => _setDebugDvConversionMode(mode.value),
),
],
);
}
Widget _buildHdrToneMappingView() {
final modes = [
(value: HdrToneMapping.compositor, subtitle: t.videoSettings.hdrToneMappingCompositorDescription),
(value: HdrToneMapping.player, subtitle: t.videoSettings.hdrToneMappingPlayerDescription),
];
final primary = Theme.of(context).colorScheme.primary;
return ListView(
children: [
for (final mode in modes)
FocusableListTile(
title: Text(
_formatHdrToneMapping(mode.value),
style: TextStyle(color: _hdrToneMapping == mode.value ? primary : null),
),
subtitle: Text(mode.subtitle, style: TextStyle(color: tokens(context).textMuted, fontSize: 12)),
trailing: _hdrToneMapping == mode.value ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
onTap: () => _setHdrToneMapping(mode.value),
),
],
);
}
Widget _buildSpeedView() {
return StreamBuilder<double>(
stream: widget.player.streams.rate,
initialData: widget.player.state.rate,
builder: (context, snapshot) {
final currentRate = snapshot.data ?? 1.0;
const speeds = <double>[
0.5,
0.75,
1.0,
1.25,
1.5,
1.75,
2.0,
2.25,
2.5,
2.75,
3.0,
3.5,
4.0,
4.5,
5.0,
6.0,
7.0,
maximumPlaybackRate,
];
return ListView.builder(
shrinkWrap: true,
itemCount: speeds.length,
itemBuilder: (context, index) {
final speed = speeds[index];
final isSelected = (currentRate - speed).abs() < 0.01;
final label = formatPlaybackRate(speed, normalAtOne: true);
final primary = Theme.of(context).colorScheme.primary;
return FocusableListTile(
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
onTap: () async {
await widget.player.setRate(speed);
// Save as default playback speed
await SettingsService.instance.write(SettingsService.defaultPlaybackSpeed, speed);
if (context.mounted) {
OverlaySheetController.of(context).close(); // Close sheet after selection
}
},
);
},
);
},
);
}
Widget _buildZoomView() {
const zoomPresets = [0.5, 0.75, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5, 1.75, 2.0];
final primary = Theme.of(context).colorScheme.primary;
return ListView(
shrinkWrap: true,
children: [
FocusableListTile(
leading: AppIcon(Symbols.restart_alt_rounded, fill: 1, color: tokens(context).textMuted),
title: Text(t.common.reset),
onTap: _resetZoomScale,
),
for (final scale in zoomPresets)
FocusableListTile(
title: Text(
_formatZoomScale(scale),
style: TextStyle(color: (_zoomScale - scale).abs() < 0.005 ? primary : null),
),
trailing: (_zoomScale - scale).abs() < 0.005
? AppIcon(Symbols.check_rounded, fill: 1, color: primary)
: null,
onTap: () => _setZoomScale(scale),
),
],
);
}
Widget _buildSleepView() {
final sleepTimer = SleepTimerService();
return SleepTimerContent(
player: widget.player,
sleepTimer: sleepTimer,
onCancel: () => OverlaySheetController.of(context).close(),
);
}
Widget _buildVersionQualityView() {
return VersionQualityPicker(
availableVersions: _state.availableVersions,
selectedMediaIndex: _state.selectedMediaIndex,
selectedQualityPreset: _state.selectedQualityPreset,
serverSupportsTranscoding: _state.serverSupportsTranscoding,
sourceDurationMs: _state.sourceDurationMs,
onVersionSelected: (index) => _state.onSwitchVersion?.call(index),
onQualitySelected: (preset) => _state.onSwitchQualityPreset?.call(preset),
);
}
/// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn").
static String _audioBackend(String name) {
final slash = name.indexOf('/');
return slash > 0 ? name.substring(0, slash) : name;
}
/// Pretty-print a backend identifier.
static String _formatBackend(String backend) {
const labels = {
'coreaudio': 'CoreAudio',
'avfoundation': 'AVFoundation',
'wasapi': 'WASAPI',
'pulse': 'PulseAudio',
'pipewire': 'PipeWire',
'alsa': 'ALSA',
'jack': 'JACK',
'oss': 'OSS',
};
return labels[backend] ?? backend;
}
Widget _buildAudioDeviceView() {
return StreamBuilder<List<AudioDevice>>(
stream: widget.player.streams.audioDevices,
initialData: widget.player.state.audioDevices,
builder: (context, snapshot) {
final devices = snapshot.data ?? [];
return StreamBuilder<AudioDevice>(
stream: widget.player.streams.audioDevice,
initialData: widget.player.state.audioDevice,
builder: (context, selectedSnapshot) {
final currentDevice = selectedSnapshot.data ?? widget.player.state.audioDevice;
// Check for duplicate descriptions (same physical device across multiple backends).
final descCounts = <String, int>{};
for (final d in devices) {
final desc = d.description.isEmpty ? d.name : d.description;
descCounts[desc] = (descCounts[desc] ?? 0) + 1;
}
final hasDuplicates = descCounts.values.any((c) => c > 1);
if (!hasDuplicates) {
return _buildFlatDeviceList(devices, currentDevice);
}
final ungrouped = <AudioDevice>[];
final groups = <String, List<AudioDevice>>{};
for (final d in devices) {
final backend = _audioBackend(d.name);
if (!d.name.contains('/')) {
ungrouped.add(d);
} else {
(groups[backend] ??= []).add(d);
}
}
return ListView(
shrinkWrap: true,
children: [
for (final d in ungrouped) _buildDeviceTile(d, currentDevice),
for (final entry in groups.entries) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
_formatBackend(entry.key),
style: TextStyle(color: tokens(context).textMuted, fontSize: 12, fontWeight: .w600),
),
),
for (final d in entry.value) _buildDeviceTile(d, currentDevice),
],
],
);
},
);
},
);
}
Widget _buildFlatDeviceList(List<AudioDevice> devices, AudioDevice currentDevice) {
// The device list arrives asynchronously, so an empty list is the normal
// first frame. Without a placeholder the shrink-wrapped page would render
// as a bare header and then jump once devices land.
//
// A fixed placeholder rather than FiltersBottomSheet's hold-the-outgoing-
// height technique: this page is entered from the menu, whose height is
// unrelated to a device list, so holding it would be arbitrary. One small
// upward move when the devices land beats two.
if (devices.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Center(
heightFactor: 1,
child: Text(t.videoControls.noAudioDevicesAvailable, style: TextStyle(color: tokens(context).textMuted)),
),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: devices.length,
itemBuilder: (context, index) => _buildDeviceTile(devices[index], currentDevice),
);
}
Widget _buildDeviceTile(AudioDevice device, AudioDevice currentDevice) {
final isSelected = device.name == currentDevice.name;
final label = device.description.isEmpty ? device.name : device.description;
final primary = Theme.of(context).colorScheme.primary;
return FocusableListTile(
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
onTap: () {
widget.player.setAudioDevice(device);
OverlaySheetController.of(context).close();
},
);
}
Widget _buildShaderView() {
if (_state.shaderService == null) return const SizedBox.shrink();
return Consumer<ShaderProvider>(
builder: (context, shaderProvider, _) {
final currentPreset = _state.shaderService!.currentPreset;
final presets = shaderProvider.allPresets;
// +1 for the import button at the end
return ListView.builder(
shrinkWrap: true,
itemCount: presets.length + 1,
itemBuilder: (context, index) {
if (index == presets.length) {
return FocusableListTile(
leading: AppIcon(Symbols.add_rounded, fill: 1, color: tokens(context).textMuted),
title: Text(t.shaders.importShader),
onTap: () => _importCustomShader(shaderProvider),
);
}
final preset = presets[index];
final isSelected = preset.id == currentPreset.id;
final isCustom = preset.type == ShaderPresetType.custom;
final presetName = preset.id == ShaderPreset.none.id ? t.common.off : preset.name;
return FocusableListTile(
title: Text(presetName, style: TextStyle(color: isSelected ? Colors.amber : null)),
subtitle: _getShaderSubtitle(preset) != null
? Text(_getShaderSubtitle(preset)!, style: TextStyle(color: tokens(context).textMuted, fontSize: 12))
: null,
trailing: Row(
mainAxisSize: .min,
children: [
if (isSelected) const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.amber),
if (isCustom) ...[
if (isSelected) const SizedBox(width: 8),
GestureDetector(
onTap: () => _deleteCustomShader(shaderProvider, preset),
child: AppIcon(Symbols.delete_rounded, fill: 1, color: tokens(context).textMuted, size: 20),
),
],
],
),
onTap: () async {
// Disable ambient lighting when selecting a shader
if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) {
_state.onToggleAmbientLighting?.call();
}
await _state.shaderService!.applyPreset(preset);
await shaderProvider.setPreset(preset);
if (!context.mounted) return;
_state.onShaderChanged?.call();
OverlaySheetController.of(context).close();
},
);
},
);
},
);
}
Future<void> _importCustomShader(ShaderProvider shaderProvider) async {
final result = await FilePickerService.instance.pickFiles(type: FileType.custom, allowedExtensions: ['glsl']);
if (result == null || result.files.isEmpty || !mounted) return;
final filePath = result.files.first.path;
if (filePath == null) return;
try {
final displayName = path.basenameWithoutExtension(filePath);
final preset = await shaderProvider.importCustomShader(filePath, displayName);
if (_state.shaderService != null && mounted) {
if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) {
_state.onToggleAmbientLighting?.call();
}
await _state.shaderService!.applyPreset(preset);
await shaderProvider.setPreset(preset);
if (!mounted) return;
_state.onShaderChanged?.call();
}
if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported);
} catch (_) {
if (mounted) showErrorSnackBar(context, t.shaders.shaderImportFailed);
}
}
Future<void> _deleteCustomShader(ShaderProvider shaderProvider, ShaderPreset preset) async {
final confirmed = await showDeleteConfirmation(
context,
title: t.shaders.deleteShader,
message: t.shaders.deleteShaderConfirm(name: preset.name),
);
if (!confirmed || !mounted) return;
// If the deleted shader is active, clear it from the player first
if (_state.shaderService!.currentPreset.id == preset.id) {
await _state.shaderService!.applyPreset(ShaderPreset.none);
if (mounted) _state.onShaderChanged?.call();
}
await shaderProvider.deleteCustomShader(preset);
}
String? _getShaderSubtitle(ShaderPreset preset) {
switch (preset.type) {
case ShaderPresetType.none:
return t.shaders.noShaderDescription;
case ShaderPresetType.nvscaler:
return t.shaders.nvscalerDescription;
case ShaderPresetType.artcnn:
if (preset.artcnnConfig != null) {
final variant = switch (preset.artcnnConfig!.variant) {
ArtCNNVariant.neutral => t.shaders.artcnnVariantNeutral,
ArtCNNVariant.denoise => t.shaders.artcnnVariantDenoise,
ArtCNNVariant.denoiseSharpen => t.shaders.artcnnVariantDenoiseSharpen,
};
return '${preset.artcnnModelDisplayName} - $variant';
}
return null;
case ShaderPresetType.anime4k:
if (preset.anime4kConfig != null) {
final quality = preset.anime4kConfig!.quality == Anime4KQuality.fast
? t.shaders.qualityFast
: t.shaders.qualityHQ;
final mode = preset.modeDisplayName;
return '$quality - ${t.shaders.mode} $mode';
}
return null;
case ShaderPresetType.custom:
return t.shaders.customShaderDescription;
}
}
@override
Widget build(BuildContext context) {
final sleepTimer = SleepTimerService();
final isShaderActive = _state.shaderService != null && _state.shaderService!.currentPreset.isEnabled;
final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001;
final isIconActive =
_currentView == _SettingsView.menu &&
(sleepTimer.isActive || _audioSyncOffset != 0 || _subtitleSyncOffset != 0 || isShaderActive || isZoomActive);
return BaseVideoControlSheet(
title: _getTitle(),
icon: _getIcon(),
iconColor: () {
if (isIconActive) return Colors.amber;
if (_currentView == _SettingsView.shader && isShaderActive) return Colors.amber;
return null;
}(),
onBack: _currentView != _SettingsView.menu ? _navigateBack : null,
child: () {
switch (_currentView) {
case _SettingsView.menu:
return _buildMenuView();
case _SettingsView.speed:
return _buildSpeedView();
case _SettingsView.zoom:
return _buildZoomView();
case _SettingsView.versionQuality:
return _buildVersionQualityView();
case _SettingsView.sleep:
return _buildSleepView();
case _SettingsView.audioSync:
case _SettingsView.subtitleSync:
return _buildMenuView(); // Sync views open as top bars, fallback to menu
case _SettingsView.audioDevice:
return _buildAudioDeviceView();
case _SettingsView.shader:
return _buildShaderView();
case _SettingsView.dvConversion:
return _buildDvConversionView();
case _SettingsView.hdrToneMapping:
return _buildHdrToneMappingView();
}
}(),
);
}
}
/// Compact sync bar shown at the top of the screen so subtitles remain visible.
class _CompactSyncBar extends StatefulWidget {
final String title;
final IconData icon;
final Player player;
final String propertyName;
final int initialOffset;
final Future<void> Function(int offset) onOffsetChanged;
final FocusNode sliderFocusNode;
const _CompactSyncBar({
required this.title,
required this.icon,
required this.player,
required this.propertyName,
required this.initialOffset,
required this.onOffsetChanged,
required this.sliderFocusNode,
});
@override
State<_CompactSyncBar> createState() => _CompactSyncBarState();
}
class _CompactSyncBarState extends State<_CompactSyncBar> {
final _resetFocusNode = FocusNode(debugLabel: 'SyncResetButton');
final _closeFocusNode = FocusNode(debugLabel: 'SyncCloseButton');
@override
void dispose() {
_resetFocusNode.dispose();
_closeFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(width: 16),
AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20),
const SizedBox(width: 8),
Text(widget.title, style: const TextStyle(fontWeight: .w600, fontSize: 14)),
Expanded(
child: SyncOffsetControl(
player: widget.player,
propertyName: widget.propertyName,
initialOffset: widget.initialOffset,
labelText: widget.title,
onOffsetChanged: widget.onOffsetChanged,
compact: true,
sliderFocusNode: widget.sliderFocusNode,
resetFocusNode: _resetFocusNode,
closeFocusNode: _closeFocusNode,
),
),
const SizedBox(width: 8),
FocusableWrapper(
focusNode: _closeFocusNode,
onSelect: () => OverlaySheetController.of(context).close(),
onNavigateLeft: () => _resetFocusNode.requestFocus(),
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: () => OverlaySheetController.of(context).close(),
child: Container(
width: 36,
height: 36,
alignment: .center,
child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22),
),
),
),
const SizedBox(width: 12),
],
);
}
}