diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12c39322..7defae69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,7 +300,8 @@ jobs: cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \ mpv_player_lifecycle_test \ mpv_property_result_contract_test \ - mpv_gpu_bootstrap_test + hdr_metadata_test \ + plane_geometry_test - name: Run Linux native reliability tests run: | diff --git a/README.md b/README.md index d2b961d0..b03d44d5 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Package managers: [^mb]: Jellyfin and Emby only. [^plex]: Plex only. [^connect]: Requires connecting the service under Settings > Services. -[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV. +[^hdr]: In-app HDR toggle on Windows, macOS, iOS, tvOS, and Linux — Linux needs a colour-managed Wayland compositor. Dolby Vision on Android and Apple TV. [^pass]: Desktop, Android TV, and Apple TV. [^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer. [^pip]: Android, iOS, and macOS — not on Android TV or Apple TV. diff --git a/lib/dev/harness_main.dart b/lib/dev/harness_main.dart new file mode 100644 index 00000000..53d0bebd --- /dev/null +++ b/lib/dev/harness_main.dart @@ -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= 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 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 _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 _reportColourState(Player player, String when) async { + const names = [ + '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 = []; + 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='); + } + } + 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 _abort(Player? player, String status) async { + await player?.dispose(); + if (!mounted) return; + setState(() { + _player = null; + _status = status; + }); + } + + Future _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), + ), + ), + ); + } +} diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 43551b6b..ac433bf9 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -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", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index c03237f9..40f8bedc 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1644,6 +1644,13 @@ "audioSync": "Синхронизация на аудио", "subtitleSync": "Синхронизация на субтитри", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "Аудио изход", "performanceOverlay": "Оверлей за производителност", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index b0ffef0d..b1c9007e 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1644,6 +1644,13 @@ "audioSync": "Lydsynkronisering", "subtitleSync": "Undertekstsynkronisering", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "Lydoutput", "performanceOverlay": "Ydelsesoverlay", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index c541e365..515bfc54 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index ec743860..ff2db241 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index c7bc0831..ab6b3ace 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index ad13a4c2..137a8015 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 13f93605..2fafb10e 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 093de129..4f58aad3 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index fe7e874f..7454f8f8 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1635,6 +1635,13 @@ "audioSync": "音声同期", "subtitleSync": "字幕同期", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "音声出力", "performanceOverlay": "パフォーマンスオーバーレイ", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 350de4a8..b7d938ec 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1644,6 +1644,13 @@ "audioSync": "Аудио синхрондау", "subtitleSync": "Субтитр синхрондау", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "Аудио шығысы", "performanceOverlay": "Өнімділік панелі", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 78036583..b19de5e3 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1635,6 +1635,13 @@ "audioSync": "오디오 동기화", "subtitleSync": "자막 동기화", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "오디오 출력", "performanceOverlay": "성능 오버레이", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index f7004f2f..6961287d 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1644,6 +1644,13 @@ "audioSync": "Lydsynkronisering", "subtitleSync": "Undertekstsynkronisering", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "Lydutgang", "performanceOverlay": "Ytelsesoverlegg", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index bf6f7525..2c177b01 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 23280577..26113796 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 3716e05a..4071489c 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 52e5a6c4..83e7892f 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1662,6 +1662,13 @@ "audioSync": "Синхронизация аудио", "subtitleSync": "Синхронизация субтитров", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "Аудиовыход", "performanceOverlay": "Оверлей производительности", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index cc75cf4f..2204ad32 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 98902d2f..d460dfe4 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index c3eb36f5..514b2e18 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index b12ea5c0..367ed2bd 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -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", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 973fe15b..c28a2006 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -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", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 4057e708..fc606f4d 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1635,6 +1635,13 @@ "audioSync": "音訊同步調整", "subtitleSync": "字幕同步調整", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "音訊輸出", "performanceOverlay": "效能監控", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 9e06b398..9895066e 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1635,6 +1635,13 @@ "audioSync": "音频同步", "subtitleSync": "字幕同步", "hdr": "HDR", + "hdrUnsupported": "", + "hdrToneMapping": "", + "hdrToneMappingCompositor": "", + "hdrToneMappingCompositorDescription": "", + "hdrToneMappingPlayer": "", + "hdrToneMappingPlayerDescription": "", + "hdrToneMappingFailed": "", "audioOutput": "音频输出", "performanceOverlay": "性能监控", "audioOutputDolbyAtmos": "Dolby Atmos", diff --git a/lib/mpv/player/platform/player_linux.dart b/lib/mpv/player/platform/player_linux.dart index e5730664..2363575a 100644 --- a/lib/mpv/player/platform/player_linux.dart +++ b/lib/mpv/player/platform/player_linux.dart @@ -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 {} diff --git a/lib/mpv/player/platform/player_windows.dart b/lib/mpv/player/platform/player_windows.dart index ecce98f4..88d2b916 100644 --- a/lib/mpv/player/platform/player_windows.dart +++ b/lib/mpv/player/platform/player_windows.dart @@ -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 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 {} diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index c05d26d4..a4cc714d 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -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 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 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) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 58487d5a..c64bade0 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -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 _textureId = ValueNotifier(null); - - @override - int? get textureId => _textureId.value; - - ValueListenable 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 updateFrame() async {} + @override + Future isHdrOutputSupported() async => false; + @override Future setVideoFrameRate( double fps, @@ -1384,7 +1379,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { Future 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(); } } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index aa47361d..4421517b 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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 _doInitialize() async { try { final result = await invoke('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 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('isHDRSupported') ?? false; + return Platform.isIOS || Platform.isMacOS || Platform.isWindows; + } } diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index c065b9e7..94aa2529 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -26,6 +26,7 @@ mixin PlayerStreamControllersMixin { final fileStartedController = StreamController.broadcast(); final fileLoadFailedController = StreamController.broadcast(); final primaryMediaReadyController = StreamController.broadcast(); + final hdrOutputChangedController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); final trackTransitionController = StreamController.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(); } } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index 0885d572..49e844a4 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -95,6 +95,13 @@ class PlayerStreams { /// subtitle sidecars finish opening. final Stream 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 hdrOutputChanged; + /// Stream of seekable buffer ranges from the demuxer cache. final Stream> bufferRanges; @@ -131,6 +138,7 @@ class PlayerStreams { this.fileStarted = const Stream.empty(), this.fileLoadFailed = const Stream.empty(), this.primaryMediaReady = const Stream.empty(), + this.hdrOutputChanged = const Stream.empty(), required this.backendSwitched, this.trackTransition = const Stream.empty(), }); diff --git a/lib/mpv/player/video_rect_support.dart b/lib/mpv/player/video_rect_support.dart index 9e5389be..6735c718 100644 --- a/lib/mpv/player/video_rect_support.dart +++ b/lib/mpv/player/video_rect_support.dart @@ -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 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, + }); + } } diff --git a/lib/mpv/video.dart b/lib/mpv/video.dart index be0ac42a..93ba464f 100644 --- a/lib/mpv/video.dart +++ b/lib/mpv/video.dart @@ -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