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.
This commit is contained in:
edde746
2026-08-10 08:48:13 +02:00
parent c27dc0a1a7
commit bcd6fe9906
76 changed files with 8025 additions and 1833 deletions
+308
View File
@@ -0,0 +1,308 @@
// Measurement harness entrypoint. NOT part of the app.
//
// Drives the real PlayerLinux/mpv/Video rendering path with a local file so
// the compositing cost can be measured without a Plex server. Build with:
// flutter build linux --target=lib/dev/harness_main.dart
// Run with:
// PLEZY_HARNESS_MEDIA=/path/to/file.mp4 PLEZY_HARNESS_SECONDS=40 ./plezy
//
// Optional knobs:
// PLEZY_HARNESS_MPV_LOG=v|debug mpv's own log stream
// PLEZY_HARNESS_MPV_PROPS=name=value,... arbitrary mpv properties, so an
// option can be swept without a
// rebuild for each value
// PLEZY_HARNESS_HDR=1 request HDR passthrough
// PLEZY_HARNESS_TONEMAP=compositor|player which side tone-maps; the A/B leg
// PLEZY_HARNESS_INSET=<px> toggles a padding every 6s, so the
// plane has to move, not just resize
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import '../mpv/models.dart';
import '../mpv/player/player.dart';
import '../mpv/video.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const _HarnessApp());
}
class _HarnessApp extends StatefulWidget {
const _HarnessApp();
@override
State<_HarnessApp> createState() => _HarnessAppState();
}
class _HarnessAppState extends State<_HarnessApp> {
Player? _player;
String _status = 'starting';
int _frames = 0;
int _buildUs = 0;
int _rasterUs = 0;
final Stopwatch _clock = Stopwatch()..start();
Timer? _reportTimer;
Timer? _probeTimer;
Timer? _quitTimer;
Timer? _insetTimer;
double _inset = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onFrames);
_reportTimer = Timer.periodic(const Duration(seconds: 2), (_) => _report());
_probeTimer = Timer.periodic(const Duration(seconds: 2), (_) => _probe());
final seconds = int.tryParse(Platform.environment['PLEZY_HARNESS_SECONDS'] ?? '');
if (seconds != null && seconds > 0) {
_quitTimer = Timer(Duration(seconds: seconds), () {
_report();
stdout.writeln('HARNESS_DONE');
exit(0);
});
}
final inset = double.tryParse(Platform.environment['PLEZY_HARNESS_INSET'] ?? '');
if (inset != null && inset > 0) {
_insetTimer = Timer.periodic(const Duration(seconds: 6), (_) {
setState(() => _inset = _inset == 0 ? inset : 0);
stdout.writeln('HARNESS_INSET $_inset');
});
}
_start();
}
void _onFrames(List<FrameTiming> timings) {
for (final t in timings) {
_frames++;
_buildUs += t.buildDuration.inMicroseconds;
_rasterUs += t.rasterDuration.inMicroseconds;
}
}
void _report() {
final n = _frames;
final secs = _clock.elapsedMilliseconds / 1000.0;
final fps = secs > 0 ? n / secs : 0.0;
final build = n > 0 ? (_buildUs / n / 1000.0) : 0.0;
final raster = n > 0 ? (_rasterUs / n / 1000.0) : 0.0;
stdout.writeln(
'FRAMESTAT t=${secs.toStringAsFixed(1)} frames=$n '
'fps=${fps.toStringAsFixed(2)} build_ms=${build.toStringAsFixed(2)} '
'raster_ms=${raster.toStringAsFixed(2)}',
);
_frames = 0;
_buildUs = 0;
_rasterUs = 0;
_clock.reset();
}
// Independent of Flutter's frame loop: tells us whether mpv is actually
// advancing even when nothing is being composited.
Future<void> _probe() async {
final player = _player;
if (player == null) return;
try {
final pos = await player.getProperty('time-pos');
final paused = await player.getProperty('pause');
final dropped = await player.getProperty('frame-drop-count');
final decoded = await player.getProperty('decoder-frame-drop-count');
stdout.writeln('PROBE time-pos=$pos pause=$paused drops=$dropped dec_drops=$decoded');
} catch (e) {
stdout.writeln('PROBE_ERROR $e');
}
}
// Which side tone-maps, and against what peak, is decided by mpv options that
// leave no trace on screen: two very different curves look like "the video
// plane works". Reading the effective values back is the only way to tell a
// deliberate target from a default nobody chose - and the render API cannot
// discover the display for itself the way a windowed mpv does, so the answer
// here is not the answer `mpv` alone would give.
//
// Read after open() because the source-dependent ones are unset until a
// format is known.
Future<void> _reportColourState(Player player, String when) async {
const names = <String>[
'target-peak',
'target-trc',
'target-prim',
'tone-mapping',
'hdr-compute-peak',
'video-params/gamma',
'video-params/primaries',
'video-params/sig-peak',
'video-params/max-luma',
];
final parts = <String>[];
for (final name in names) {
try {
parts.add('$name=${await player.getProperty(name) ?? "-"}');
} catch (_) {
// The message is dropped rather than interpolated: this line is parsed
// as space-separated name=value pairs, and an exception string carries
// spaces of its own.
parts.add('$name=<error>');
}
}
stdout.writeln('HARNESS_COLOUR[$when] ${parts.join(' ')}');
}
// build() only shows _status while there is no player, and by this point one
// has usually been assigned. Without clearing it a deliberate abort renders as
// an empty transparent window, i.e. indistinguishable from a hang.
Future<void> _abort(Player? player, String status) async {
await player?.dispose();
if (!mounted) return;
setState(() {
_player = null;
_status = status;
});
}
Future<void> _start() async {
final media = Platform.environment['PLEZY_HARNESS_MEDIA'];
if (media == null || media.isEmpty) {
setState(() => _status = 'set PLEZY_HARNESS_MEDIA');
return;
}
final uri = media.startsWith('/') ? 'file://$media' : media;
try {
final player = Player();
setState(() => _player = player);
final level = Platform.environment['PLEZY_HARNESS_MPV_LOG'];
if (level != null && level.isNotEmpty) {
await player.setLogLevel(level);
stdout.writeln('HARNESS_MPV_LOG $level');
}
// Example: PLEZY_HARNESS_MPV_PROPS='tone-mapping=mobius,target-peak=200'.
// Names the plugin intercepts (hdr-enabled, hdr-tone-mapping) also have
// dedicated knobs, and those are applied *after* this block, so the
// dedicated one wins if both name the same thing. Both writes are logged
// and HARNESS_COLOUR[settled] reads the effective state back, so a capture
// cannot be silently mislabelled either way.
//
// Applied before open(), so the first configured frame already has them.
//
// That timing is also the catch, and it has already produced a wrong
// answer: the native side re-applies the whole output description on
// playback-restart and on every seek, so anything it manages -
// target-peak, target-prim, target-trc, tone-mapping - is overwritten
// moments later. A sweep of those reads back as the shipped value while
// looking perfectly plausible. Use this for properties the runner does not
// set itself; for the ones it does, change the code.
//
// A malformed or rejected override aborts the leg, for the same reason
// PLEZY_HARNESS_TONEMAP does: these captures get labelled with the value
// that was asked for, and carrying on would file the default curve under
// whatever was requested. A silently wrong label is worse than no capture.
final props = Platform.environment['PLEZY_HARNESS_MPV_PROPS'];
if (props != null && props.isNotEmpty) {
for (final pair in props.split(',')) {
final split = pair.indexOf('=');
final name = split > 0 ? pair.substring(0, split).trim() : '';
final value = split > 0 ? pair.substring(split + 1).trim() : '';
String? failure;
if (name.isEmpty || value.isEmpty) {
failure = 'not name=value';
} else {
try {
await player.setProperty(name, value);
stdout.writeln('HARNESS_MPV_PROP $name=$value');
} catch (e) {
failure = '$e';
}
}
if (failure != null) {
stdout.writeln('HARNESS_MPV_PROP_ERROR $pair: $failure');
await _abort(player, 'bad PLEZY_HARNESS_MPV_PROPS: $pair');
return;
}
}
}
// Selected before hdr-enabled so the first description built already uses
// the requested mode; the native side re-applies either way.
//
// A rejected value aborts instead of carrying on. This harness exists to
// produce A/B photographs, and continuing in whatever mode happened to be
// active would label the result with a leg that was never shown.
final toneMapping = Platform.environment['PLEZY_HARNESS_TONEMAP'];
if (toneMapping != null && toneMapping.isNotEmpty) {
try {
await player.setProperty('hdr-tone-mapping', toneMapping);
stdout.writeln('HARNESS_TONEMAP $toneMapping');
} catch (e) {
stdout.writeln('HARNESS_TONEMAP_ERROR $e');
await _abort(player, 'bad PLEZY_HARNESS_TONEMAP: $toneMapping');
return;
}
}
if (Platform.environment['PLEZY_HARNESS_HDR'] == '1') {
try {
await player.setProperty('hdr-enabled', 'yes');
stdout.writeln('HARNESS_HDR requested');
} catch (e) {
stdout.writeln('HARNESS_HDR_ERROR $e');
}
}
await player.open(Media(uri));
stdout.writeln('HARNESS_OPENED $uri');
// Twice, and the second one is the one that means anything. The native
// side applies the output description from playback-restart, which has
// not fired yet: reading only here reports the pre-HDR defaults and makes
// a working transaction look like it never ran. The delayed read is the
// independent check that mpv actually holds what the transaction logged
// asking for - our own log says what was requested, not what landed.
await _reportColourState(player, 'open');
Timer(const Duration(seconds: 6), () async {
if (!mounted || _player != player) return;
await _reportColourState(player, 'settled');
});
} catch (e, st) {
stdout.writeln('HARNESS_ERROR $e\n$st');
await _abort(_player, 'error: $e');
}
}
@override
void dispose() {
_reportTimer?.cancel();
_probeTimer?.cancel();
_quitTimer?.cancel();
_insetTimer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onFrames);
// Reported, not ignored: this harness exists to say what mpv did, and a
// teardown that failed is part of that.
if (_player case final player?) {
unawaited(player.dispose().catchError((Object e) => stdout.writeln('HARNESS_DISPOSE_FAILED $e')));
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final player = _player;
return MaterialApp(
debugShowCheckedModeBanner: false,
// Transparent all the way down: in plane mode the video is a Wayland
// subsurface *below* this surface, so anything opaque here hides it.
home: Scaffold(
backgroundColor: Colors.transparent,
body: player == null
? Center(
child: Text(_status, style: const TextStyle(color: Colors.white)),
)
: Padding(
// The inset moves the plane to a non-zero origin, which a plain
// window resize would never exercise.
padding: EdgeInsets.all(_inset),
child: Video(player: player, backgroundColor: Colors.transparent),
),
),
);
}
}
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Səs sinxronizasiyası",
"subtitleSync": "Altyazı sinxronizasiyası",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Səs çıxışı",
"performanceOverlay": "Məhsuldarlıq paneli",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Синхронизация на аудио",
"subtitleSync": "Синхронизация на субтитри",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио изход",
"performanceOverlay": "Оверлей за производителност",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydoutput",
"performanceOverlay": "Ydelsesoverlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio-Synchronisation",
"subtitleSync": "Untertitel-Synchronisation",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio Sync",
"subtitleSync": "Subtitle Sync",
"hdr": "HDR",
"hdrUnsupported": "HDR isn't available here — this desktop compositor or video output can't carry it.",
"hdrToneMapping": "HDR Tone Mapping",
"hdrToneMappingCompositor": "Compositor",
"hdrToneMappingCompositorDescription": "Pass the source's HDR metadata through and let the desktop compositor map it.",
"hdrToneMappingPlayer": "Player",
"hdrToneMappingPlayerDescription": "Map to the display's peak brightness in the player, then tell the compositor the result.",
"hdrToneMappingFailed": "Couldn't change HDR tone mapping \u2014 the previous mode is still active.",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronización de audio",
"subtitleSync": "Sincronización de subtítulos",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Synchronisation audio",
"subtitleSync": "Synchronisation des sous-titres",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Sortie audio",
"performanceOverlay": "Données de performance",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Hang szinkronizálása",
"subtitleSync": "Felirat szinkronizálása",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Hangkimenet",
"performanceOverlay": "Teljesítményadatok",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronizzazione audio",
"subtitleSync": "Sincronizzazione sottotitoli",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音声同期",
"subtitleSync": "字幕同期",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音声出力",
"performanceOverlay": "パフォーマンスオーバーレイ",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Аудио синхрондау",
"subtitleSync": "Субтитр синхрондау",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудио шығысы",
"performanceOverlay": "Өнімділік панелі",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "오디오 동기화",
"subtitleSync": "자막 동기화",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Lydsynkronisering",
"subtitleSync": "Undertekstsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Lydutgang",
"performanceOverlay": "Ytelsesoverlegg",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audiosynchronisatie",
"subtitleSync": "Ondertitelsynchronisatie",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1662,6 +1662,13 @@
"audioSync": "Synchronizacja audio",
"subtitleSync": "Synchronizacja napisów",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Wyjście audio",
"performanceOverlay": "Nakładka wydajności",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Sincronia de áudio",
"subtitleSync": "Sincronia de legendas",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Saída de áudio",
"performanceOverlay": "Painel de desempenho",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1662,6 +1662,13 @@
"audioSync": "Синхронизация аудио",
"subtitleSync": "Синхронизация субтитров",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Аудиовыход",
"performanceOverlay": "Оверлей производительности",
"audioOutputDolbyAtmos": "Dolby Atmos",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 38338 (1742 per locale)
/// Strings: 38345 (1742 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+30 -2
View File
@@ -4115,6 +4115,27 @@ class Translations$videoSettings$en {
/// en: 'HDR'
String get hdr => 'HDR';
/// en: 'HDR isn't available here — this desktop compositor or video output can't carry it.'
String get hdrUnsupported => 'HDR isn\'t available here — this desktop compositor or video output can\'t carry it.';
/// en: 'HDR Tone Mapping'
String get hdrToneMapping => 'HDR Tone Mapping';
/// en: 'Compositor'
String get hdrToneMappingCompositor => 'Compositor';
/// en: 'Pass the source's HDR metadata through and let the desktop compositor map it.'
String get hdrToneMappingCompositorDescription => 'Pass the source\'s HDR metadata through and let the desktop compositor map it.';
/// en: 'Player'
String get hdrToneMappingPlayer => 'Player';
/// en: 'Map to the display's peak brightness in the player, then tell the compositor the result.'
String get hdrToneMappingPlayerDescription => 'Map to the display\'s peak brightness in the player, then tell the compositor the result.';
/// en: 'Couldn't change HDR tone mapping — the previous mode is still active.'
String get hdrToneMappingFailed => 'Couldn\'t change HDR tone mapping — the previous mode is still active.';
/// en: 'Audio Output'
String get audioOutput => 'Audio Output';
@@ -7594,6 +7615,13 @@ extension on Translations {
'videoSettings.audioSync' => 'Audio Sync',
'videoSettings.subtitleSync' => 'Subtitle Sync',
'videoSettings.hdr' => 'HDR',
'videoSettings.hdrUnsupported' => 'HDR isn\'t available here — this desktop compositor or video output can\'t carry it.',
'videoSettings.hdrToneMapping' => 'HDR Tone Mapping',
'videoSettings.hdrToneMappingCompositor' => 'Compositor',
'videoSettings.hdrToneMappingCompositorDescription' => 'Pass the source\'s HDR metadata through and let the desktop compositor map it.',
'videoSettings.hdrToneMappingPlayer' => 'Player',
'videoSettings.hdrToneMappingPlayerDescription' => 'Map to the display\'s peak brightness in the player, then tell the compositor the result.',
'videoSettings.hdrToneMappingFailed' => 'Couldn\'t change HDR tone mapping — the previous mode is still active.',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioOutputDolbyAtmos' => 'Dolby Atmos',
@@ -7631,6 +7659,8 @@ extension on Translations {
'performanceOverlay.maxLuma' => 'Max Luma',
'performanceOverlay.minLuma' => 'Min Luma',
'performanceOverlay.maxCll' => 'MaxCLL',
_ => null,
} ?? switch (path) {
'performanceOverlay.maxFall' => 'MaxFALL',
'performanceOverlay.cacheUsed' => 'Cache Used',
'performanceOverlay.cacheLimit' => 'Cache Limit',
@@ -7638,8 +7668,6 @@ extension on Translations {
'performanceOverlay.player' => 'Player',
'performanceOverlay.memory' => 'Memory',
'performanceOverlay.uiFps' => 'UI FPS',
_ => null,
} ?? switch (path) {
'externalPlayer.title' => 'External Player',
'externalPlayer.useExternalPlayer' => 'Use External Player',
'externalPlayer.useExternalPlayerDescription' => 'Open videos in another app',
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Ljudsynkronisering",
"subtitleSync": "Undertextsynkronisering",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Ses Senkronizasyonu",
"subtitleSync": "Altyazı Senkronizasyonu",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Ses Çıkışı",
"performanceOverlay": "Performans Katmanı",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1644,6 +1644,13 @@
"audioSync": "Audio sinxronlash",
"subtitleSync": "Subtitr sinxronlash",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "Audio chiqishi",
"performanceOverlay": "Unumdorlik paneli",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音訊同步調整",
"subtitleSync": "字幕同步調整",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音訊輸出",
"performanceOverlay": "效能監控",
"audioOutputDolbyAtmos": "Dolby Atmos",
+7
View File
@@ -1635,6 +1635,13 @@
"audioSync": "音频同步",
"subtitleSync": "字幕同步",
"hdr": "HDR",
"hdrUnsupported": "",
"hdrToneMapping": "",
"hdrToneMappingCompositor": "",
"hdrToneMappingCompositorDescription": "",
"hdrToneMappingPlayer": "",
"hdrToneMappingPlayerDescription": "",
"hdrToneMappingFailed": "",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控",
"audioOutputDolbyAtmos": "Dolby Atmos",
+9 -3
View File
@@ -1,5 +1,11 @@
import '../player_native.dart';
import '../video_rect_support.dart';
/// Uses libmpv with FlTextureGL — video rendered to an offscreen FBO
/// and composited GPU-side via Flutter's Texture widget.
class PlayerLinux extends PlayerNative {}
/// Uses libmpv on a native Wayland video plane — Linux's only render path.
///
/// Video goes to a `wl_subsurface` stacked below the Flutter surface, the same
/// shape Windows gets from a child HWND: geometry arrives via
/// [VideoRectSupport.setVideoRect] and Flutter never composites the frames.
/// A session that cannot host the plane fails `initialize` outright rather than
/// degrading, so the user sees the reason instead of a black rectangle.
class PlayerLinux extends PlayerNative with VideoRectSupport {}
+4 -22
View File
@@ -2,25 +2,7 @@ import '../player_native.dart';
import '../video_rect_support.dart';
/// Uses libmpv with native window embedding behind the Flutter window.
class PlayerWindows extends PlayerNative with VideoRectSupport {
// Native window embedding, not a Flutter texture.
@override
int? get textureId => null;
@override
Future<void> setVideoRect({
required int left,
required int top,
required int right,
required int bottom,
required double devicePixelRatio,
}) async {
await invoke('setVideoRect', {
'left': left,
'top': top,
'right': right,
'bottom': bottom,
'devicePixelRatio': devicePixelRatio,
});
}
}
///
/// mpv renders into a child HWND, so Flutter only ever tells it where to sit
/// (see [VideoRectSupport]).
class PlayerWindows extends PlayerNative with VideoRectSupport {}
+9 -7
View File
@@ -67,12 +67,6 @@ abstract class Player {
/// this first.
bool get audioPassthroughActive;
/// Texture ID for Flutter's Texture widget (video rendering).
///
/// This is set by the platform implementation when video
/// rendering is initialized. Returns null if not ready.
int? get textureId;
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
String get playerType;
@@ -267,6 +261,14 @@ abstract class Player {
/// On other platforms, this is a no-op.
Future<void> updateFrame();
/// Whether this player's video output can currently carry HDR.
///
/// A query rather than a constant because on Linux it genuinely varies: the
/// native side needs a 10-bit plane, a compositor advertising the source's
/// transfer function and BT.2020, and an output the compositor reports as
/// being in HDR. Moving the window to an SDR monitor changes the answer.
Future<bool> isHdrOutputSupported();
/// Set the video frame rate for display refresh rate matching.
///
/// On Android, this hints the system to adjust the display refresh rate
@@ -374,7 +376,7 @@ abstract class Player {
/// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering
/// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback)
/// - Windows: [PlayerWindows] using libmpv with native window embedding
/// - Linux: [PlayerLinux] using libmpv with OpenGL rendering via GtkGLArea
/// - Linux: [PlayerLinux] using libmpv on a native Wayland video plane
///
/// On Android, pass [useExoPlayer] to override the default:
/// - true: Use ExoPlayer (default, better hardware support)
+8 -15
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier, protected, visibleForTesting;
import 'package:flutter/foundation.dart' show protected, visibleForTesting;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
@@ -49,18 +49,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@override
PlayerStreams get streams => _streams;
final ValueNotifier<int?> _textureId = ValueNotifier<int?>(null);
@override
int? get textureId => _textureId.value;
ValueListenable<int?> get textureIdListenable => _textureId;
@protected
void setTextureId(int? value) {
if (!_disposed) _textureId.value = value;
}
StreamSubscription? _eventSubscription;
StreamSubscription? _logSubscription;
bool _disposed = false;
@@ -558,6 +546,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
playbackRestartController.add(null);
break;
case 'hdr-output-changed':
hdrOutputChangedController.add(null);
break;
case 'log-message':
final rawPrefix = data?['prefix'];
final rawLevel = data?['level'];
@@ -1072,6 +1064,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
// ignore: no-empty-block - base no-op, overridden by platform subclasses
Future<void> updateFrame() async {}
@override
Future<bool> isHdrOutputSupported() async => false;
@override
Future<bool> setVideoFrameRate(
double fps,
@@ -1384,7 +1379,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
Future<void> dispose({bool preserveDisplayMode = false}) async {
if (_disposed) return;
_disposed = true;
_textureId.value = null;
final channelName = eventChannel.name;
if (identical(_eventChannelOwners[channelName], this)) {
@@ -1434,7 +1428,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
);
}
await closeStreamControllers();
_textureId.dispose();
}
}
+30 -21
View File
@@ -56,9 +56,16 @@ class PlayerNative extends PlayerBase {
@visibleForTesting
static bool debugForceContentFdConversion = false;
/// Overrides the Linux-only video readiness handshake in host tests.
/// Overrides the Linux video-plane detection in host tests.
@visibleForTesting
static bool? debugUseLinuxVideoBootstrap;
static bool? debugUseLinuxVideoPlane;
/// Whether this process drives video through the Linux Wayland plane, which
/// is `Platform.isLinux` and nothing finer — Linux has no other render path.
///
/// The one place the test override is resolved, so production code and host
/// tests agree on which path is live without reading a test-only field.
static bool get usesLinuxVideoPlane => debugUseLinuxVideoPlane ?? Platform.isLinux;
// Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
@@ -181,10 +188,6 @@ class PlayerNative extends PlayerBase {
);
}
/// Whether the UI must mount the provisional texture before initialization
/// can complete its first render/bootstrap handshake.
bool get requiresProvisionalTextureSurface => !audioOnly && (debugUseLinuxVideoBootstrap ?? Platform.isLinux);
// Memoizes the in-flight init Future so concurrent callers (e.g. the
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
@@ -213,20 +216,7 @@ class PlayerNative extends PlayerBase {
Future<void> _doInitialize() async {
try {
final result = await invoke<Object>('initialize');
final bool ok;
if (result is int) {
// Linux publishes a provisional texture so Flutter can invoke
// FlTextureGL::populate. Playback stays gated until native GPU
// bootstrap reports that the texture is usable.
setTextureId(result);
if (debugUseLinuxVideoBootstrap ?? Platform.isLinux) {
await invoke('waitForVideoReady');
}
ok = true;
} else {
ok = result == true;
}
if (!ok) {
if (result != true) {
throw Exception('Failed to initialize player');
}
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
@@ -255,7 +245,6 @@ class PlayerNative extends PlayerBase {
if (_nativeCoreUnavailable) throw StateError('Player was disposed during initialization');
initialized = true;
} catch (e) {
setTextureId(null);
_initFuture = null;
if (!_nativeCoreUnavailable) {
errorController.add(PlayerError('Initialization failed: $e'));
@@ -1062,4 +1051,24 @@ class PlayerNative extends PlayerBase {
if (_nativeCoreUnavailable || !Platform.isAndroid || !initialized) return;
await invoke('abandonAudioFocus');
}
/// See [Player.isHdrOutputSupported] for why this is a query and not a constant.
///
/// Only Linux delegates to the native side, because only there does the answer
/// move: it folds in the output the plane currently sits on. Everywhere else it
/// is a platform constant. Windows has a native query of its own, but nothing
/// consults this method there - the settings sheet offers HDR on Windows
/// unconditionally - so asking would only let the two disagree about the same
/// platform. Nothing is cached on either path.
@override
Future<bool> isHdrOutputSupported() async {
// No video plane without video, on any platform, so this precedes the
// platform question rather than sitting inside one branch of it.
if (_nativeCoreUnavailable || audioOnly) return false;
// Asked through usesLinuxVideoPlane, not Platform.isLinux, so this and the
// settings sheet's _probesHdrSupport resolve the same way under the test
// override; on a real Linux host the two are the same answer.
if (usesLinuxVideoPlane) return await invoke<bool>('isHDRSupported') ?? false;
return Platform.isIOS || Platform.isMacOS || Platform.isWindows;
}
}
@@ -26,6 +26,7 @@ mixin PlayerStreamControllersMixin {
final fileStartedController = StreamController<void>.broadcast();
final fileLoadFailedController = StreamController<void>.broadcast();
final primaryMediaReadyController = StreamController<void>.broadcast();
final hdrOutputChangedController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
final trackTransitionController = StreamController<String>.broadcast();
@@ -54,6 +55,7 @@ mixin PlayerStreamControllersMixin {
fileLoadFailed: fileLoadFailedController.stream,
primaryMediaReady: primaryMediaReadyController.stream,
backendSwitched: backendSwitchedController.stream,
hdrOutputChanged: hdrOutputChangedController.stream,
trackTransition: trackTransitionController.stream,
);
}
@@ -82,6 +84,7 @@ mixin PlayerStreamControllersMixin {
await fileLoadFailedController.close();
await primaryMediaReadyController.close();
await backendSwitchedController.close();
await hdrOutputChangedController.close();
await trackTransitionController.close();
}
}
+8
View File
@@ -95,6 +95,13 @@ class PlayerStreams {
/// subtitle sidecars finish opening.
final Stream<void> primaryMediaReady;
/// Emits when the compositor's preferred colour description for the video
/// plane changes: the window moved to another output, or an output's HDR
/// state was toggled under it. Linux only, where it is the only notice that
/// [Player.isHdrOutputSupported] may now answer differently - dragging a
/// window between monitors raises no app lifecycle event on Wayland.
final Stream<void> hdrOutputChanged;
/// Stream of seekable buffer ranges from the demuxer cache.
final Stream<List<BufferRange>> bufferRanges;
@@ -131,6 +138,7 @@ class PlayerStreams {
this.fileStarted = const Stream<void>.empty(),
this.fileLoadFailed = const Stream<void>.empty(),
this.primaryMediaReady = const Stream<void>.empty(),
this.hdrOutputChanged = const Stream<void>.empty(),
required this.backendSwitched,
this.trackTransition = const Stream<String>.empty(),
});
+19 -3
View File
@@ -1,11 +1,27 @@
import 'player.dart';
import 'player_native.dart';
mixin VideoRectSupport on Player {
/// Players whose video lives in a native surface behind the Flutter window,
/// and so must be told where to put it.
///
/// Constrained to [PlayerNative] because the request is the same on every such
/// platform — a Windows child HWND and a Wayland subsurface take identical
/// geometry — so the mixin carries the call instead of each platform repeating
/// it. `Video` keys its layout reporting off this type: mixing it in is what
/// says the player has a surface worth positioning.
mixin VideoRectSupport on PlayerNative {
Future<void> setVideoRect({
required int left,
required int top,
required int right,
required int bottom,
required double devicePixelRatio,
});
}) async {
await invoke('setVideoRect', {
'left': left,
'top': top,
'right': right,
'bottom': bottom,
'devicePixelRatio': devicePixelRatio,
});
}
}
+78 -30
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'models.dart';
import 'player/player.dart';
import 'player/video_rect_support.dart';
@@ -39,7 +40,16 @@ class Video extends StatefulWidget {
}
class _VideoState extends State<Video> {
Rect? _lastRect;
// The integer physical bounds last handed to the native side, and the scale
// that went with them. Cached as what was *sent*, not as the logical rect it
// was derived from, because the rounding in _updateVideoRect is what decides
// whether a layout change is visible to the plane at all.
bool _hasSentRect = false;
int _sentLeft = 0;
int _sentTop = 0;
int _sentRight = 0;
int _sentBottom = 0;
double _sentDevicePixelRatio = 0;
bool _hasFirstFrame = false;
StreamSubscription<void>? _playbackRestartSubscription;
@@ -63,6 +73,11 @@ class _VideoState extends State<Video> {
_playbackRestartSubscription?.cancel();
_listenForPlaybackRestart();
_syncExternalFirstFrame();
// The cache describes the old player's native surface. Keeping it would
// let the next frame short-circuit as "geometry unchanged", and the
// replacement surface stays sizeless — invisible, with no Texture
// fallback left to cover for it.
_hasSentRect = false;
}
}
@@ -108,21 +123,6 @@ class _VideoState extends State<Video> {
}
Widget _buildVideoSurface() {
final player = widget.player;
if (player is PlayerBase) {
return ValueListenableBuilder<int?>(
valueListenable: player.textureIdListenable,
builder: (context, textureId, _) => _buildVideoSurfaceForId(textureId),
);
}
return _buildVideoSurfaceForId(player.textureId);
}
Widget _buildVideoSurfaceForId(int? textureId) {
if (textureId != null) {
return Texture(textureId: textureId);
}
if (widget.player is VideoRectSupport) {
return LayoutBuilder(
builder: (context, constraints) {
@@ -144,24 +144,72 @@ class _VideoState extends State<Video> {
final size = renderBox.size;
final dpr = MediaQuery.devicePixelRatioOf(context);
final newRect = Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
// Rounded outward, the same way the native SetRect biases: it floors the
// position and rounds the buffer size up so the plane always covers at
// least the region Flutter cut out for it. Truncating the far edges here
// would undo that a layer earlier - at a fractional layout position the
// plane comes up a physical pixel short and the desktop shows through the
// seam, where the point of the plane is that the seam is black. Ceil and
// floor are identity on an already-integral value, so an integral layout
// sends exactly the numbers it sent before.
final left = (position.dx * dpr).floor();
final top = (position.dy * dpr).floor();
final right = ((position.dx + size.width) * dpr).ceil();
final bottom = ((position.dy + size.height) * dpr).ceil();
if (_lastRect != null &&
(newRect.left - _lastRect!.left).abs() < 1 &&
(newRect.top - _lastRect!.top).abs() < 1 &&
(newRect.width - _lastRect!.width).abs() < 1 &&
(newRect.height - _lastRect!.height).abs() < 1) {
// Keyed on the four integers actually sent rather than on a logical-pixel
// tolerance. A sub-logical-pixel move is a real move at scale 2 or 3 -
// worth up to three physical pixels of stale placement - while anything too
// small to change one of these numbers cannot reach the plane at all and is
// not worth the channel round-trip.
//
// The scale is part of what the native side is being told, so it has to be
// part of what decides whether to tell it. Moving a window between a
// scale-1 and a scale-2 output can leave all four bounds identical while
// devicePixelRatio changes, and dropping that call leaves the native
// surface at the old buffer resolution: soft at half resolution after
// docking to a HiDPI output, overdrawn at double after undocking. The Steam
// Deck's dock is exactly this.
if (_hasSentRect &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
return;
}
_lastRect = newRect;
_hasSentRect = true;
_sentLeft = left;
_sentTop = top;
_sentRight = right;
_sentBottom = bottom;
_sentDevicePixelRatio = dpr;
(widget.player as VideoRectSupport).setVideoRect(
left: (position.dx * dpr).toInt(),
top: (position.dy * dpr).toInt(),
right: ((position.dx + size.width) * dpr).toInt(),
bottom: ((position.dy + size.height) * dpr).toInt(),
devicePixelRatio: dpr,
);
final player = widget.player as VideoRectSupport;
player.setVideoRect(left: left, top: top, right: right, bottom: bottom, devicePixelRatio: dpr).catchError((
Object e,
) {
// Geometry is the only thing that makes the native surface visible,
// so a rejected rect is a black video area, not a cosmetic glitch.
// Post-frame callbacks have nobody to rethrow to, so route it to the
// player's error stream rather than leaving an unhandled async error.
//
// Drop the sent-rect cache too: it was recorded before the call
// resolved, and keeping it would short-circuit every later identical
// layout pass, freezing the failure in place. Cleared, the next layout
// or resize retries for free.
if (mounted &&
_sentLeft == left &&
_sentTop == top &&
_sentRight == right &&
_sentBottom == bottom &&
_sentDevicePixelRatio == dpr) {
_hasSentRect = false;
}
if (!player.errorController.isClosed) {
player.errorController.add(PlayerError('Failed to set video rect: $e'));
}
});
}
}
-17
View File
@@ -82,23 +82,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
);
}
Widget _buildPlayerInitializationSurface() {
final bootstrapPlayer = _bootstrapPlayer;
if (bootstrapPlayer == null) return _buildLoadingSpinner();
// Linux creates the texture before its EGL/mpv render bootstrap can be
// proven. Mount the provisional surface so Flutter drives one texture
// copy, while retaining the black loading cover until playback itself
// reports its first frame.
return Stack(
fit: StackFit.expand,
children: [
Video(player: bootstrapPlayer, hasFirstFrame: _hasFirstFrame),
const Center(child: PlayerLoadingIndicator()),
],
);
}
Widget _buildInitializationError(String message) {
return Scaffold(
backgroundColor: Colors.black,
+144 -17
View File
@@ -129,6 +129,47 @@ part 'video_player/parts/watch_together.dart';
final WakelockController _wakelockController = WakelockController();
/// Property names the free-form mpv config is not allowed to write.
///
/// Neither is an mpv property. The Linux plugin intercepts both by name and
/// moves its own persistent HDR state instead (linux/runner/mpv/mpv_plugin.cc),
/// and the custom config is applied *after* startup has pushed the stored
/// preferences, so a `hdr-enabled=yes` or `hdr-tone-mapping=player` line would
/// change the live plane without anything writing it back to [SettingsService].
/// The settings sheet renders its HDR switch and tone-mapping row straight off
/// those preferences with no native readback, so the UI would report one state
/// while the plane held another - for the whole session, and again after a
/// restart, since the next startup replays the same order rather than
/// reconciling.
///
/// Filtered rather than reordered: reordering would still leave the config as a
/// second writer of state the app owns, silently discarded on every startup
/// instead of silently winning. Nothing legitimate is lost - both names are
/// settings the player's own HDR controls already expose, and mean nothing to
/// mpv itself, so no platform is losing a real mpv property here.
const _appInterceptedMpvProperties = {'hdr-enabled', 'hdr-tone-mapping'};
/// The above, plus the four real mpv properties the Linux video plane owns.
///
/// It writes all four as one unit and caches what it last applied so it can skip
/// a transaction that would change nothing. A config line writing one of them
/// moves mpv without moving that cache, and the next transaction then compares
/// against a value mpv no longer holds and skips the write it needed to make -
/// leaving mpv encoding one colour space while the surface is described as
/// another, the single state the two-phase apply exists to prevent.
///
/// Scoped to the plane deliberately. These are ordinary mpv properties
/// everywhere else, nothing caches them there, and no other platform exposes a
/// UI control for them - so withholding them off Linux would remove the user's
/// only way to set them and point the log at a control they do not have.
const _appOwnedMpvProperties = {
..._appInterceptedMpvProperties,
'target-trc',
'target-prim',
'target-peak',
'tone-mapping',
};
/// Whether an in-place source reload may start the replacement media.
///
/// Reloading a paused player must not manufacture a new play intent. Watch
@@ -410,7 +451,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
static bool isNavigationActive(VideoPlayerLaunchIdentity identity) => _activeRouteGuard.blocks(identity);
Player? player;
Player? _bootstrapPlayer;
VideoVolumeController? _volumeController;
bool _isPlayerInitialized = false;
String? _playerInitializationError;
@@ -1149,9 +1189,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (identical(player, attemptPlayer)) {
player = null;
}
if (identical(_bootstrapPlayer, attemptPlayer)) {
_bootstrapPlayer = null;
}
try {
await _tearDownFailedPlayerAttempt(attemptPlayer);
} catch (e, st) {
@@ -1223,9 +1260,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final currentPlayer = Player(useExoPlayer: useExoPlayer);
attemptPlayer = currentPlayer;
if (!mounted || generation != _playerInitializationGeneration) return;
if (currentPlayer is PlayerNative && currentPlayer.requiresProvisionalTextureSurface) {
setState(() => _bootstrapPlayer = currentPlayer);
}
if (Platform.isAndroid && useExoPlayer) {
await currentPlayer.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
if (!mounted || generation != _playerInitializationGeneration) return;
@@ -1409,10 +1443,90 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await currentPlayer.setAudioPassthrough(settingsService.read(SettingsService.audioPassthrough));
}
// HDR is controlled via custom hdr-enabled property on iOS/macOS/Windows
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) {
// Set before hdr-enabled so the first image description is already built
// for the chosen mode. Unlike hdr-enabled below, every failure here is
// swallowed: an older libmpv rejects it as an unknown property, with no
// code to tell that apart, and a tone-mapping preference is never a reason
// to fail playback.
if (PlayerNative.usesLinuxVideoPlane) {
final toneMapping = settingsService.read(SettingsService.hdrToneMapping);
try {
await currentPlayer.setProperty('hdr-tone-mapping', toneMapping.name);
} catch (e) {
appLogger.d('VideoPlayerScreen: HDR tone-mapping mode not applied', error: e);
// A refused transaction leaves the plugin on the mode it last accepted,
// and nothing has moved it off the compositor default this session -
// the only writer is this push, plus the sheet, which persists solely
// on success. Storing that back keeps the sheet from offering "Player"
// as the current mode while the plane tone-maps in the compositor,
// a disagreement no later write would correct on its own.
// Contained on its own, for the same reason as the hdr-enabled block
// below: the refusal is deliberately tolerated, so a preference store
// that then throws must not turn "carry on with compositor tone
// mapping" into a failed player initialization.
if (toneMapping != HdrToneMapping.compositor) {
try {
await settingsService.write(SettingsService.hdrToneMapping, HdrToneMapping.compositor);
} catch (writeError) {
appLogger.w('VideoPlayerScreen: could not reconcile the stored tone-mapping mode', error: writeError);
}
}
}
}
// HDR is controlled via the custom hdr-enabled property. On Linux it means
// "allow passthrough": the native side only describes the plane as HDR
// when the compositor, the output and the source all agree, so pushing the
// preference here is safe even when it cannot be honoured.
//
// Linux swallows every refusal, because on Linux a refusal is a statement
// about the *plane*, not about the media: HDR_UNSUPPORTED means this
// session's plane can never carry HDR - an 8-bit EGL config, or a
// compositor without the colour-management pieces - and a failed colour
// transaction means mpv would not take the output properties. Neither is a
// reason not to play the video in SDR, so rethrowing would turn "this
// session cannot do HDR" into "this session cannot play video": the
// initialization error screen, with a Retry that fails the same way.
//
// Two earlier reasons given here no longer hold and are recorded as gone
// so they are not reinstated: the packages no longer link a distro libmpv
// (each ships the pinned build), and the plugin intercepts hdr-enabled
// whenever a video surface exists, so the old fall-through to mpv's
// target-colorspace-hint - and its mpv 0.40 version floor - is unreachable.
//
// The tolerance is Linux-only rather than "every platform, for this one
// error code". HDR_UNSUPPORTED is produced by the Linux plugin and nothing
// else, so tolerating it elsewhere would be an inert branch no test on any
// runner can reach, and a silent change to what the other platforms did
// before this feature existed.
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
final enableHDR = settingsService.read(SettingsService.enableHDR);
await currentPlayer.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
try {
await currentPlayer.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
} catch (e) {
if (!PlayerNative.usesLinuxVideoPlane) rethrow;
appLogger.d('VideoPlayerScreen: HDR passthrough not applied', error: e);
// Same hazard as the tone-mapping block above. A refused transaction
// hands hdr_wanted back to whatever it held before this write, and
// nothing has moved it this session: the plugin is freshly created and
// zero-initialised, so it is off. Storing that back keeps the settings
// switch - which renders straight off this preference - from reading
// on while the plane is SDR, a disagreement no later write corrects
// because every internal re-apply reads the native side instead.
// Contained on its own. The refusal above is deliberately tolerated -
// this session simply plays SDR - so a preference store that then
// throws must not escalate that into the initialization error screen,
// which is where an escape from this catch lands. Worst case the
// preference stays out of step, which is the situation before this
// reconciliation existed.
if (enableHDR) {
try {
await settingsService.write(SettingsService.enableHDR, false);
} catch (writeError) {
appLogger.w('VideoPlayerScreen: could not reconcile the stored HDR preference', error: writeError);
}
}
}
}
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
@@ -1446,7 +1560,24 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
// Only the Linux video plane owns the four real mpv properties, so only
// there are they withheld. Elsewhere nothing caches them and a config line
// is the user's single way to reach them - dropping it would take away
// something that worked, and point at a control that platform does not
// show. The two intercepted names are not mpv properties anywhere, so
// those stay withheld everywhere.
final ownedHere = PlayerNative.usesLinuxVideoPlane ? _appOwnedMpvProperties : _appInterceptedMpvProperties;
for (final entry in customMpvConfig.entries) {
// Not silently dropped: the user typed this line, so say which one went
// unapplied and where to set it instead, at the same level as the other
// skipped or failed startup writes below.
if (ownedHere.contains(entry.key)) {
appLogger.w(
'Skipped custom MPV property ${entry.key}=${entry.value}: the app owns it, '
'set it in the player HDR settings instead',
);
continue;
}
try {
await currentPlayer.setProperty(entry.key, entry.value);
appLogger.d('Applied custom MPV property: ${entry.key}=${entry.value}');
@@ -1479,10 +1610,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!_ownsPlayerInitializationAttempt(generation, currentPlayer)) return;
if (mounted) {
setState(() {
_isPlayerInitialized = true;
_bootstrapPlayer = null;
});
setState(() => _isPlayerInitialized = true);
// Restart sleep timer if we're starting a new playback session
SleepTimerService().restartIfNeeded(() => unawaited(_pauseWithPlaybackIntent(currentPlayer)));
@@ -1852,9 +1980,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final volumeController = _volumeController;
_volumeController = null;
volumeController?.dispose();
final playerToDispose = player ?? _bootstrapPlayer;
final playerToDispose = player;
player = null;
_bootstrapPlayer = null;
if (playerToDispose != null) {
// Keep the native display mode (tvOS HDMI criteria) across a
// player→player handoff; the replacement screen primes its own.
@@ -2261,7 +2388,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
? _buildVideoPlayer(sheetContext)
: (_playerInitializationError != null
? _buildInitializationError(_playerInitializationError!)
: _buildPlayerInitializationSurface()),
: _buildLoadingSpinner()),
),
),
);
+21
View File
@@ -59,6 +59,15 @@ enum SubAssOverride { no, yes, scale, force, strip }
/// [quarter] trade sharpness for raster throughput on render-bound low-end TVs.
enum SubtitleRenderResolution { screen, video, threeQuarter, half, third, quarter }
/// Who reduces HDR content to what the display can actually show, on the Linux
/// native video plane.
///
/// [compositor] hands the compositor the source's own metadata and lets its tone
/// curve do the work — simple, and what Kodi does. [player] tone-maps in mpv to
/// the display's real peak and declares that peak instead, which is mpv's own
/// default and leaves the compositor an identity transform.
enum HdrToneMapping { compositor, player }
extension SubtitleRenderScale on SubtitleRenderResolution {
/// Android libass overlay render scale (fraction of the surface resolution).
/// Only Android reads this; the iOS-only [video] basis maps to full scale here.
@@ -341,6 +350,17 @@ class SettingsService extends BaseSharedPreferencesService {
static const crashReporting = BoolPref('crash_reporting', defaultValue: true);
static const enableHardwareDecoding = BoolPref('enable_hardware_decoding', defaultValue: true);
static const enableHDR = BoolPref('enable_hdr', defaultValue: true);
// Linux native video plane only. Defaults to the compositor: photographed on a
// 400-nit HDR output against a PQ chart, the compositor keeps 400 -> 1000 nits
// monotonic and separated while the player leg flattens it. The player path
// drives mpv's legacy vo_gpu, whose own standalone output scores the same, so
// the gap is the renderer rather than the wiring. Compositor also needs no
// knowledge of the display.
static const hdrToneMapping = EnumPref<HdrToneMapping>(
'hdr_tone_mapping',
values: HdrToneMapping.values,
defaultValue: HdrToneMapping.compositor,
);
static const preferredVideoCodec = StringPref('preferred_video_codec', defaultValue: 'auto');
static const preferredAudioCodec = StringPref('preferred_audio_codec', defaultValue: 'auto');
static const viewMode = EnumPref<ViewMode>('view_mode', values: ViewMode.values, defaultValue: ViewMode.grid);
@@ -889,6 +909,7 @@ class SettingsService extends BaseSharedPreferencesService {
enableDebugLogging,
enableHardwareDecoding,
enableHDR,
hdrToneMapping,
preferredVideoCodec,
preferredAudioCodec,
viewMode,
@@ -14,6 +14,7 @@ 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';
@@ -48,6 +49,7 @@ enum _SettingsView {
audioDevice,
shader,
dvConversion,
hdrToneMapping,
}
class _SettingsMenuItem extends StatelessWidget {
@@ -92,6 +94,76 @@ class _SettingsMenuItem extends StatelessWidget {
}
}
/// 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;
@@ -105,8 +177,6 @@ class _SettingsToggleItem extends StatefulWidget {
}
class _SettingsToggleItemState extends State<_SettingsToggleItem> {
static final LatestAsyncWrite<String> _writes = LatestAsyncWrite<String>();
bool? _pendingValue;
int _writeGeneration = 0;
@@ -129,7 +199,7 @@ class _SettingsToggleItemState extends State<_SettingsToggleItem> {
final pref = widget.pref;
final callback = widget.onAfterWrite;
final generation = ++_writeGeneration;
final writeToken = _writes.begin(pref.key);
final writeToken = _prefWrites.begin(pref.key);
setState(() {
_pendingValue = next;
});
@@ -144,10 +214,11 @@ class _SettingsToggleItemState extends State<_SettingsToggleItem> {
int writeToken,
) async {
try {
final committed = await _writes.commitIfLatest(pref.key, writeToken, () async {
if (callback != null) await callback(next);
await SettingsService.instance.write(pref, next);
});
final committed = await _prefWrites.commitIfLatest(
pref.key,
writeToken,
() => _applyThenPersist(pref, next, callback),
);
if (!committed || !mounted || generation != _writeGeneration) return;
setState(() {
_pendingValue = null;
@@ -274,11 +345,32 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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 ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows);
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;
@@ -292,7 +384,20 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_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
@@ -313,6 +418,79 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
});
}
// 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;
@@ -397,6 +575,9 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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();
}
@@ -422,6 +603,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return t.shaders.title;
case _SettingsView.dvConversion:
return 'DV Conversion Mode';
case _SettingsView.hdrToneMapping:
return t.videoSettings.hdrToneMapping;
}
}
@@ -447,6 +630,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return Symbols.auto_fix_high_rounded;
case _SettingsView.dvConversion:
return Symbols.hdr_strong_rounded;
case _SettingsView.hdrToneMapping:
return Symbols.tonality_rounded;
}
}
@@ -468,6 +653,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
};
}
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;
@@ -604,7 +794,18 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
pref: SettingsService.enableHDR,
icon: Symbols.hdr_strong_rounded,
title: t.videoSettings.hdr,
onAfterWrite: (value) => widget.player.setProperty('hdr-enabled', value ? 'yes' : 'no'),
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
@@ -770,6 +971,29 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
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,
@@ -1168,6 +1392,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildShaderView();
case _SettingsView.dvConversion:
return _buildDvConversionView();
case _SettingsView.hdrToneMapping:
return _buildHdrToneMappingView();
}
}(),
);