fix(playback): use display criteria for matching
This commit is contained in:
@@ -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]'), '');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user