From 5df3ec0c12cb7a1e78dad6b3cb24bc6245f3bde9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 15 May 2026 19:46:04 +0200 Subject: [PATCH] fix(playback): use display criteria for matching --- lib/media/media_display_criteria.dart | 20 ++++++++++- lib/media/media_source_info.dart | 2 -- .../video_player/parts/display_matching.dart | 9 +++-- .../parts/episode_navigation.dart | 5 ++- .../video_player/parts/playback_start.dart | 7 ++-- lib/services/display_mode_service.dart | 14 ++++++-- lib/services/file_info_parser.dart | 33 ++----------------- lib/services/jellyfin_media_info_mapper.dart | 1 - lib/services/plex_mappers.dart | 1 - lib/services/plex_playback_mapper.dart | 1 - test/services/file_info_parser_test.dart | 15 ++++----- test/services/jellyfin_media_info_test.dart | 4 +-- test/services/plex_playback_mapper_test.dart | 2 +- 13 files changed, 59 insertions(+), 55 deletions(-) diff --git a/lib/media/media_display_criteria.dart b/lib/media/media_display_criteria.dart index b36b8a54..4531e642 100644 --- a/lib/media/media_display_criteria.dart +++ b/lib/media/media_display_criteria.dart @@ -51,10 +51,25 @@ class MediaDisplayCriteria { bool get hasDimensions => (width ?? 0) > 0 && (height ?? 0) > 0; + bool get hasFrameRate => (fps ?? 0) > 0; + bool get hasDisplayMetadata => (doviProfile ?? 0) > 0 || _hasValue(transfer) || _hasValue(primaries) || _hasValue(matrix); - bool get isUsable => hasDimensions && hasDisplayMetadata; + bool get canPrimeNativeDisplayCriteria => hasDimensions && hasDisplayMetadata; + + bool get isHdr { + if ((doviProfile ?? 0) > 0 && doviCompatibilityId != 2) return true; + final tags = _normalizedColorTags(transfer, primaries, matrix); + return tags.contains('hlg') || + tags.contains('arib') || + tags.contains('pq') || + tags.contains('smpte2084') || + tags.contains('st2084') || + tags.contains('bt2020'); + } + + bool get isUsable => hasFrameRate || canPrimeNativeDisplayCriteria; Map toJson() { final json = {}; @@ -81,3 +96,6 @@ String? _stringOrNull(Object? value) { } bool _hasValue(String? value) => value != null && value.isNotEmpty; + +String _normalizedColorTags(String? transfer, String? primaries, String? matrix) => + [transfer, primaries, matrix].whereType().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 67aeeeeb..ffeb3210 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -8,7 +8,6 @@ class MediaSourceInfo { final List subtitleTracks; final List chapters; final int? partId; - final double? frameRate; final MediaDisplayCriteria? displayCriteria; /// Jellyfin source id for the *selected* version (null on Plex). Lets the @@ -32,7 +31,6 @@ class MediaSourceInfo { required this.subtitleTracks, required this.chapters, this.partId, - this.frameRate, this.displayCriteria, this.mediaSourceId, this.defaultAudioStreamIndex, diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index 11de026b..b73b4887 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -115,13 +115,18 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { if (player == null || _displayModeService == null) return; try { + final displayCriteria = _isTranscoding ? null : _currentMediaInfo?.displayCriteria; final fpsStr = await player!.getProperty('container-fps'); - final fps = double.tryParse(fpsStr ?? ''); + final fallbackFps = double.tryParse(fpsStr ?? ''); final sigPeakStr = await player!.getProperty('video-params/sig-peak'); final sigPeak = double.tryParse(sigPeakStr ?? ''); - final delay = await _displayModeService!.applyDisplayMatching(fps: fps, sigPeak: sigPeak); + final delay = await _displayModeService!.applyDisplayMatching( + criteria: displayCriteria, + fallbackFps: fallbackFps, + fallbackSigPeak: sigPeak, + ); if (delay > Duration.zero) { await Future.delayed(delay); diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 467f51cf..5493a622 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -214,7 +214,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final hasExternalSubs = result.externalSubtitles.isNotEmpty; final isExoPlayer = player is PlayerAndroid; - await currentPlayer.setDisplayCriteria(result.isTranscoding ? null : result.mediaInfo?.displayCriteria); + final displayCriteria = result.mediaInfo?.displayCriteria; + await currentPlayer.setDisplayCriteria( + !result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, + ); await currentPlayer.open( Media(result.videoUrl!, start: resumePosition, headers: streamHeaders), play: isExoPlayer || !hasExternalSubs, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 4f3a7ae1..306dacb0 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -181,7 +181,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // paused and switch before visible playback starts. final settingsService = await SettingsService.getInstance(); if (!mounted || player != currentPlayer) return; - final preKnownFps = result.mediaInfo?.frameRate; + final displayCriteria = result.mediaInfo?.displayCriteria; + final preKnownFps = displayCriteria?.fps; final willAutoSwitch = Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate) && @@ -284,7 +285,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { ); } - await currentPlayer.setDisplayCriteria(result.isTranscoding ? null : result.mediaInfo?.displayCriteria); + await currentPlayer.setDisplayCriteria( + !result.isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, + ); final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs); if (needsAndroidMpvStartupRefresh) { diff --git a/lib/services/display_mode_service.dart b/lib/services/display_mode_service.dart index 67e38889..55a8ebb6 100644 --- a/lib/services/display_mode_service.dart +++ b/lib/services/display_mode_service.dart @@ -2,9 +2,10 @@ import 'dart:io'; import 'package:flutter/services.dart'; +import '../media/media_display_criteria.dart'; +import '../utils/app_logger.dart'; import 'fullscreen_state_manager.dart'; import 'settings_service.dart'; -import '../utils/app_logger.dart'; /// Orchestrates Windows display mode matching (refresh rate, HDR) during video playback. /// Uses the same platform channel as the mpv player (com.plezy/mpv_player). @@ -24,7 +25,11 @@ class DisplayModeService { /// Apply display matching based on video properties. Returns the delay /// duration to wait before starting playback. - Future applyDisplayMatching({required double? fps, required double? sigPeak}) async { + Future applyDisplayMatching({ + MediaDisplayCriteria? criteria, + required double? fallbackFps, + required double? fallbackSigPeak, + }) async { if (!Platform.isWindows) return Duration.zero; if (!_fullscreen.isFullscreen) { appLogger.d('Display matching skipped: not in fullscreen'); @@ -32,6 +37,8 @@ class DisplayModeService { } bool anyChange = false; + final criteriaFps = criteria?.fps; + final fps = criteriaFps != null && criteriaFps > 0 ? criteriaFps : fallbackFps; if (_settings.read(SettingsService.matchRefreshRate) && fps != null && fps > 0) { try { @@ -42,7 +49,8 @@ class DisplayModeService { } } - if (_settings.read(SettingsService.matchDynamicRange) && sigPeak != null && sigPeak > 1.0) { + final shouldEnableHdr = criteria?.isHdr == true || (fallbackSigPeak != null && fallbackSigPeak > 1.0); + if (_settings.read(SettingsService.matchDynamicRange) && shouldEnableHdr) { try { final success = await _enableSystemHDR(); anyChange |= success; diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 3595eca5..c0f44172 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -6,8 +6,7 @@ import 'plex_constants.dart'; /// per-source stream list (video/audio/subtitle entries) as `List` /// under different field names; the per-backend [FileInfoStreamReader] /// implementations encapsulate the naming differences so the call sites in -/// each client can hand a streams array straight to [walkStreams] and read -/// the four-tuple result. +/// each client can hand a streams array straight to [walkStreams]. enum FileInfoStreamType { video, audio, subtitle } /// Normalised projection of a single entry in Jellyfin's `MediaStreams` array. @@ -58,23 +57,15 @@ class FileInfoStreams { final Map? audioStream; final List audioTracks; final List subtitleTracks; - final double? frameRate; const FileInfoStreams({ required this.videoStream, required this.audioStream, required this.audioTracks, required this.subtitleTracks, - required this.frameRate, }); - static const empty = FileInfoStreams( - videoStream: null, - audioStream: null, - audioTracks: [], - subtitleTracks: [], - frameRate: null, - ); + static const empty = FileInfoStreams(videoStream: null, audioStream: null, audioTracks: [], subtitleTracks: []); } abstract class FileInfoStreamReader { @@ -90,11 +81,6 @@ abstract class FileInfoStreamReader { /// Build a neutral [MediaSubtitleTrack] from a backend-specific subtitle /// entry. See [autoIndex] note on [toAudioTrack]. MediaSubtitleTrack toSubtitleTrack(Map stream, int autoIndex); - - /// Pull the playback frame rate out of the video stream entry. Used by - /// callers that build a [MediaSourceInfo] for the player so the renderer - /// can pick the right refresh-rate match on capable displays. - double? frameRateOf(Map videoStream); } typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map stream); @@ -102,7 +88,7 @@ typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTra /// Walk [streams] in a single pass. Captures the first video / audio entries /// (later ones are ignored — both backends serve a single primary track per /// type), accumulates *all* audio / subtitle tracks for selection UIs, and -/// extracts the frame rate from the video entry. +/// keeps the raw video stream for display-metadata parsing. FileInfoStreams walkStreams( List? streams, FileInfoStreamReader reader, { @@ -113,7 +99,6 @@ FileInfoStreams walkStreams( final subtitleTracks = []; Map? videoStream; Map? audioStream; - double? frameRate; var audioIndex = 0; var subtitleIndex = 0; for (final raw in streams) { @@ -124,7 +109,6 @@ FileInfoStreams walkStreams( switch (type) { case FileInfoStreamType.video: videoStream ??= raw; - frameRate ??= reader.frameRateOf(raw); case FileInfoStreamType.audio: audioStream ??= raw; audioIndex++; @@ -143,7 +127,6 @@ FileInfoStreams walkStreams( audioStream: audioStream, audioTracks: audioTracks, subtitleTracks: subtitleTracks, - frameRate: frameRate, ); } @@ -196,11 +179,6 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader { key: stream['key'] as String?, ); } - - @override - double? frameRateOf(Map videoStream) { - return flexibleDouble(videoStream['frameRate']); - } } /// Reader for Jellyfin's `MediaSources[].MediaStreams[]` entries. Field @@ -255,9 +233,4 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader { external: f.isExternal, ); } - - @override - double? frameRateOf(Map videoStream) { - return (videoStream['RealFrameRate'] as num?)?.toDouble() ?? (videoStream['AverageFrameRate'] as num?)?.toDouble(); - } } diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 62e27825..8674cf70 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -59,7 +59,6 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo( subtitleTracks: subtitleTracks, chapters: mappedChapters, partId: partId, - frameRate: parsedStreams.frameRate, displayCriteria: _jellyfinDisplayCriteria(source, parsedStreams.videoStream), mediaSourceId: mediaSourceId, defaultAudioStreamIndex: defaultAudioStreamIndex, diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index ac32662c..46345b8d 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -1047,7 +1047,6 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map metadata, audioTracks: streams.audioTracks, subtitleTracks: streams.subtitleTracks, chapters: const [], - frameRate: streams.frameRate, displayCriteria: PlexMappers.displayCriteriaFromJson( selectedMedia is Map ? selectedMedia : null, streams.videoStream, diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index 14ab87c5..a3c0b8d3 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -58,7 +58,6 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( subtitleTracks: streams.subtitleTracks, chapters: chapters, partId: part['id'] as int?, - frameRate: streams.frameRate, displayCriteria: PlexMappers.displayCriteriaFromJson(media as Map?, streams.videoStream), ); } diff --git a/test/services/file_info_parser_test.dart b/test/services/file_info_parser_test.dart index 11477523..dbbe34cd 100644 --- a/test/services/file_info_parser_test.dart +++ b/test/services/file_info_parser_test.dart @@ -5,7 +5,7 @@ import 'package:plezy/services/file_info_parser.dart'; /// represented by its own [FileInfoStreamReader] implementation, so the /// tests fix two things: /// 1. The walker's accounting (single video pointer, every audio + sub -/// tracked, frame rate captured once). +/// tracked, raw video stream retained once). /// 2. Each reader's mapping from raw JSON to the neutral track classes. void main() { group('walkStreams (Plex reader)', () { @@ -50,7 +50,7 @@ void main() { expect(out.videoStream?['id'], 100); expect(out.audioStream?['id'], 101); - expect(out.frameRate, closeTo(23.976, 1e-6)); + expect(out.videoStream?['frameRate'], closeTo(23.976, 1e-6)); expect(out.audioTracks.map((t) => t.id), [101, 102]); expect(out.audioTracks[0].channels, 6); expect(out.audioTracks[0].selected, isTrue); @@ -74,7 +74,7 @@ void main() { expect(out.audioTracks, isEmpty); expect(out.subtitleTracks, isEmpty); expect(out.videoStream?['id'], 3); - expect(out.frameRate, 24.0); + expect(out.videoStream?['frameRate'], 24); }); test('skips non-Map entries gracefully', () { @@ -84,7 +84,6 @@ void main() { expect(out.audioStream, isNull); expect(out.audioTracks, isEmpty); expect(out.subtitleTracks, isEmpty); - expect(out.frameRate, isNull); }); }); @@ -111,7 +110,7 @@ void main() { expect(out.videoStream?['Index'], 0); expect(out.audioStream?['Index'], 1); - expect(out.frameRate, closeTo(23.976, 1e-6)); + expect(out.videoStream?['RealFrameRate'], closeTo(23.976, 1e-6)); expect(out.audioTracks.map((t) => t.id), [1, 2]); expect(out.audioTracks[0].selected, isTrue); expect(out.audioTracks[0].languageCode, 'eng'); @@ -130,12 +129,12 @@ void main() { expect(out.audioTracks.map((t) => t.id), [1, 7, 3]); }); - test('frameRateOf falls back to AverageFrameRate when RealFrameRate is null', () { + test('captures video stream when only AverageFrameRate is present', () { final streams = [ {'Type': 'Video', 'AverageFrameRate': 25.0}, ]; final out = walkStreams(streams, reader); - expect(out.frameRate, 25.0); + expect(out.videoStream?['AverageFrameRate'], 25.0); }); test('skips streams with unknown Type', () { @@ -172,7 +171,7 @@ void main() { expect(jf.audioTracks, hasLength(1)); expect(plex.subtitleTracks, hasLength(1)); expect(jf.subtitleTracks, hasLength(1)); - expect(plex.frameRate, jf.frameRate); + expect(plex.videoStream?['frameRate'], jf.videoStream?['RealFrameRate']); expect(plex.audioTracks.first.codec, jf.audioTracks.first.codec); expect(plex.audioTracks.first.channels, jf.audioTracks.first.channels); expect(plex.audioTracks.first.selected, jf.audioTracks.first.selected); diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index af5b0a4d..cb163ade 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -51,7 +51,7 @@ void main() { expect(info.audioTracks.length, 2); expect(info.subtitleTracks.length, 1); - expect(info.frameRate, closeTo(23.976, 0.001)); + expect(info.displayCriteria?.fps, closeTo(23.976, 0.001)); // Plex partId is null on Jellyfin because Jellyfin persists selected // stream indexes through playback progress reports instead. expect(info.getPartId(), isNull); @@ -142,7 +142,7 @@ void main() { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x'}); expect(info.audioTracks, isEmpty); expect(info.subtitleTracks, isEmpty); - expect(info.frameRate, isNull); + expect(info.displayCriteria, isNull); }); test('uses Jellyfin default stream indexes over per-stream default flags', () { diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index dc476cf1..029eb213 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -44,7 +44,7 @@ void main() { expect(result.availableVersions, hasLength(2)); expect(result.availableVersions.first.isPlayable, isFalse); expect(result.mediaInfo?.partId, 20); - expect(result.mediaInfo?.frameRate, 23.976); + expect(result.mediaInfo?.displayCriteria?.fps, 23.976); expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); });