fix(playback): use display criteria for matching

This commit is contained in:
edde746
2026-05-15 19:46:04 +02:00
parent ef4980db09
commit 5df3ec0c12
13 changed files with 59 additions and 55 deletions
+19 -1
View File
@@ -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<String, Object> toJson() {
final json = <String, Object>{};
@@ -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<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
-2
View File
@@ -8,7 +8,6 @@ class MediaSourceInfo {
final List<MediaSubtitleTrack> subtitleTracks;
final List<MediaChapter> 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,
@@ -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);
@@ -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,
@@ -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) {
+11 -3
View File
@@ -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<Duration> applyDisplayMatching({required double? fps, required double? sigPeak}) async {
Future<Duration> 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;
+3 -30
View File
@@ -6,8 +6,7 @@ import 'plex_constants.dart';
/// per-source stream list (video/audio/subtitle entries) as `List<dynamic>`
/// 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<String, dynamic>? audioStream;
final List<MediaAudioTrack> audioTracks;
final List<MediaSubtitleTrack> 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<String, dynamic> 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<String, dynamic> videoStream);
}
typedef MalformedStreamHandler = void Function(Object error, StackTrace stackTrace, Map<String, dynamic> 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<dynamic>? streams,
FileInfoStreamReader reader, {
@@ -113,7 +99,6 @@ FileInfoStreams walkStreams(
final subtitleTracks = <MediaSubtitleTrack>[];
Map<String, dynamic>? videoStream;
Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> videoStream) {
return (videoStream['RealFrameRate'] as num?)?.toDouble() ?? (videoStream['AverageFrameRate'] as num?)?.toDouble();
}
}
@@ -59,7 +59,6 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
subtitleTracks: subtitleTracks,
chapters: mappedChapters,
partId: partId,
frameRate: parsedStreams.frameRate,
displayCriteria: _jellyfinDisplayCriteria(source, parsedStreams.videoStream),
mediaSourceId: mediaSourceId,
defaultAudioStreamIndex: defaultAudioStreamIndex,
-1
View File
@@ -1047,7 +1047,6 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map<String, dynamic> metadata,
audioTracks: streams.audioTracks,
subtitleTracks: streams.subtitleTracks,
chapters: const [],
frameRate: streams.frameRate,
displayCriteria: PlexMappers.displayCriteriaFromJson(
selectedMedia is Map<String, dynamic> ? selectedMedia : null,
streams.videoStream,
-1
View File
@@ -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<String, dynamic>?, streams.videoStream),
);
}
+7 -8
View File
@@ -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);
+2 -2
View File
@@ -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', () {
+1 -1
View File
@@ -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');
});