fix(tvos): prime display criteria from metadata
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import '../utils/json_utils.dart';
|
||||
|
||||
/// Backend-neutral display metadata used to prime native display matching
|
||||
/// before the decoder has emitted mpv/video properties.
|
||||
class MediaDisplayCriteria {
|
||||
final double? fps;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final int? doviProfile;
|
||||
final int? doviLevel;
|
||||
final int? doviCompatibilityId;
|
||||
final String? transfer;
|
||||
final String? primaries;
|
||||
final String? matrix;
|
||||
|
||||
const MediaDisplayCriteria({
|
||||
this.fps,
|
||||
this.width,
|
||||
this.height,
|
||||
this.doviProfile,
|
||||
this.doviLevel,
|
||||
this.doviCompatibilityId,
|
||||
this.transfer,
|
||||
this.primaries,
|
||||
this.matrix,
|
||||
});
|
||||
|
||||
factory MediaDisplayCriteria.fromRaw({
|
||||
Object? fps,
|
||||
Object? width,
|
||||
Object? height,
|
||||
Object? doviProfile,
|
||||
Object? doviLevel,
|
||||
Object? doviCompatibilityId,
|
||||
Object? transfer,
|
||||
Object? primaries,
|
||||
Object? matrix,
|
||||
}) {
|
||||
return MediaDisplayCriteria(
|
||||
fps: flexibleDouble(fps),
|
||||
width: flexibleInt(width),
|
||||
height: flexibleInt(height),
|
||||
doviProfile: flexibleInt(doviProfile),
|
||||
doviLevel: flexibleInt(doviLevel),
|
||||
doviCompatibilityId: flexibleInt(doviCompatibilityId),
|
||||
transfer: _stringOrNull(transfer),
|
||||
primaries: _stringOrNull(primaries),
|
||||
matrix: _stringOrNull(matrix),
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasDimensions => (width ?? 0) > 0 && (height ?? 0) > 0;
|
||||
|
||||
bool get hasDisplayMetadata =>
|
||||
(doviProfile ?? 0) > 0 || _hasValue(transfer) || _hasValue(primaries) || _hasValue(matrix);
|
||||
|
||||
bool get isUsable => hasDimensions && hasDisplayMetadata;
|
||||
|
||||
Map<String, Object> toJson() {
|
||||
final json = <String, Object>{};
|
||||
void put(String key, Object? value) {
|
||||
if (value != null) json[key] = value;
|
||||
}
|
||||
|
||||
put('fps', fps);
|
||||
put('width', width);
|
||||
put('height', height);
|
||||
put('doviProfile', doviProfile);
|
||||
put('doviLevel', doviLevel);
|
||||
put('doviCompatibilityId', doviCompatibilityId);
|
||||
put('transfer', transfer);
|
||||
put('primaries', primaries);
|
||||
put('matrix', matrix);
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
String? _stringOrNull(Object? value) {
|
||||
final string = value?.toString().trim();
|
||||
return string == null || string.isEmpty ? null : string;
|
||||
}
|
||||
|
||||
bool _hasValue(String? value) => value != null && value.isNotEmpty;
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/track_label_builder.dart' show TrackLabelBuilder, buildTrackLabel;
|
||||
import 'media_display_criteria.dart';
|
||||
|
||||
class MediaSourceInfo {
|
||||
final String videoUrl;
|
||||
@@ -8,6 +9,7 @@ class MediaSourceInfo {
|
||||
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
|
||||
/// trickplay loader request the right tile sheet when an item has multiple
|
||||
@@ -31,6 +33,7 @@ class MediaSourceInfo {
|
||||
required this.chapters,
|
||||
this.partId,
|
||||
this.frameRate,
|
||||
this.displayCriteria,
|
||||
this.mediaSourceId,
|
||||
this.defaultAudioStreamIndex,
|
||||
this.defaultSubtitleStreamIndex,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import '../../media/media_display_criteria.dart';
|
||||
import '../models.dart';
|
||||
import 'platform/player_android.dart';
|
||||
import 'player_native.dart';
|
||||
@@ -146,6 +147,10 @@ abstract class Player {
|
||||
/// [args] - Command and arguments as a list of strings.
|
||||
Future<void> command(List<String> args);
|
||||
|
||||
/// Prime native display matching from server metadata before the decoder
|
||||
/// emits stream properties. Unsupported platforms ignore this.
|
||||
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria);
|
||||
|
||||
/// Configure subtitle fonts for libass rendering.
|
||||
///
|
||||
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../media/media_display_criteria.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/track_label_builder.dart';
|
||||
import '../font_loader.dart';
|
||||
@@ -522,6 +523,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {}
|
||||
|
||||
@override
|
||||
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
|
||||
if (_disposed) return false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../media/media_display_criteria.dart';
|
||||
import '../models.dart';
|
||||
import 'player_base.dart';
|
||||
|
||||
@@ -222,6 +223,13 @@ class PlayerNative extends PlayerBase {
|
||||
await invoke('command', {'args': args});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {
|
||||
if (disposed || !Platform.isIOS) return;
|
||||
await _ensureInitialized();
|
||||
await invoke('setDisplayCriteria', {'criteria': criteria?.toJson()});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setLogLevel(String level) async {
|
||||
if (disposed) return;
|
||||
|
||||
@@ -214,6 +214,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
|
||||
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
|
||||
final isExoPlayer = player is PlayerAndroid;
|
||||
await currentPlayer.setDisplayCriteria(result.isTranscoding ? null : result.mediaInfo?.displayCriteria);
|
||||
await currentPlayer.open(
|
||||
Media(result.videoUrl!, start: resumePosition, headers: streamHeaders),
|
||||
play: isExoPlayer || !hasExternalSubs,
|
||||
|
||||
@@ -284,6 +284,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
);
|
||||
}
|
||||
|
||||
await currentPlayer.setDisplayCriteria(result.isTranscoding ? null : result.mediaInfo?.displayCriteria);
|
||||
|
||||
final shouldAutoPlay = !shouldHoldPlaybackStart && (isExoPlayer || !hasExternalSubs);
|
||||
if (needsAndroidMpvStartupRefresh) {
|
||||
appLogger.d('Frame rate matching: opening Android MPV paused for startup buffer flush');
|
||||
|
||||
@@ -199,7 +199,7 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader {
|
||||
|
||||
@override
|
||||
double? frameRateOf(Map<String, dynamic> videoStream) {
|
||||
return (videoStream['frameRate'] as num?)?.toDouble();
|
||||
return flexibleDouble(videoStream['frameRate']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../media/media_display_criteria.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../media/media_source_info.dart';
|
||||
import '../utils/jellyfin_time.dart';
|
||||
@@ -59,6 +60,7 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
chapters: mappedChapters,
|
||||
partId: partId,
|
||||
frameRate: parsedStreams.frameRate,
|
||||
displayCriteria: _jellyfinDisplayCriteria(source, parsedStreams.videoStream),
|
||||
mediaSourceId: mediaSourceId,
|
||||
defaultAudioStreamIndex: defaultAudioStreamIndex,
|
||||
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
|
||||
@@ -66,6 +68,74 @@ MediaSourceInfo jellyfinMediaSourceToMediaSourceInfo(
|
||||
);
|
||||
}
|
||||
|
||||
MediaDisplayCriteria? _jellyfinDisplayCriteria(Map<String, dynamic> source, Map<String, dynamic>? videoStream) {
|
||||
if (videoStream == null) return null;
|
||||
|
||||
final doviProfile = flexibleInt(videoStream['DvProfile']);
|
||||
final doviCompatibilityId = flexibleInt(videoStream['DvBlSignalCompatibilityId']);
|
||||
final videoRangeType = videoStream['VideoRangeType']?.toString().toLowerCase();
|
||||
final videoRange = videoStream['VideoRange']?.toString().toLowerCase();
|
||||
final transfer = _stringOrNull(videoStream['ColorTransfer']);
|
||||
final primaries = _stringOrNull(videoStream['ColorPrimaries']);
|
||||
final matrix = _stringOrNull(videoStream['ColorSpace']);
|
||||
final defaults = _jellyfinDefaultDisplayColorTags(
|
||||
videoRangeType: videoRangeType,
|
||||
videoRange: videoRange,
|
||||
doviCompatibilityId: doviCompatibilityId,
|
||||
transfer: transfer,
|
||||
primaries: primaries,
|
||||
matrix: matrix,
|
||||
);
|
||||
final criteria = MediaDisplayCriteria.fromRaw(
|
||||
fps: videoStream['RealFrameRate'] ?? videoStream['AverageFrameRate'],
|
||||
width: videoStream['Width'] ?? source['Width'],
|
||||
height: videoStream['Height'] ?? source['Height'],
|
||||
doviProfile: doviProfile,
|
||||
doviLevel: videoStream['DvLevel'],
|
||||
doviCompatibilityId: doviCompatibilityId,
|
||||
transfer: transfer ?? defaults.transfer,
|
||||
primaries: primaries ?? defaults.primaries,
|
||||
matrix: matrix ?? defaults.matrix,
|
||||
);
|
||||
return criteria.isUsable ? criteria : null;
|
||||
}
|
||||
|
||||
({String? transfer, String? primaries, String? matrix}) _jellyfinDefaultDisplayColorTags({
|
||||
required String? videoRangeType,
|
||||
required String? videoRange,
|
||||
int? doviCompatibilityId,
|
||||
String? transfer,
|
||||
String? primaries,
|
||||
String? matrix,
|
||||
}) {
|
||||
final range = '${videoRangeType ?? ''} ${videoRange ?? ''}';
|
||||
final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix);
|
||||
if (doviCompatibilityId == 4 || range.contains('hlg') || colorTags.contains('hlg') || colorTags.contains('arib')) {
|
||||
return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc');
|
||||
}
|
||||
if (doviCompatibilityId == 1 ||
|
||||
doviCompatibilityId == 6 ||
|
||||
range.contains('hdr') ||
|
||||
colorTags.contains('smpte2084') ||
|
||||
colorTags.contains('st2084') ||
|
||||
colorTags.contains('pq') ||
|
||||
colorTags.contains('bt2020')) {
|
||||
return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc');
|
||||
}
|
||||
if (doviCompatibilityId == 2 || range.trim().isEmpty || range.contains('sdr')) {
|
||||
return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709');
|
||||
}
|
||||
return (transfer: null, primaries: null, matrix: null);
|
||||
}
|
||||
|
||||
String? _stringOrNull(Object? value) {
|
||||
final string = value?.toString().trim();
|
||||
return string == null || string.isEmpty ? null : string;
|
||||
}
|
||||
|
||||
String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) =>
|
||||
[transfer, primaries, matrix].whereType<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
|
||||
List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, int? defaultStreamIndex) {
|
||||
if (defaultStreamIndex == null) return tracks;
|
||||
return [
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_display_criteria.dart';
|
||||
import '../media/media_hub.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
@@ -107,6 +108,14 @@ Object? _readMetadataRatingKey(Map json, String _) => (json['ratingKey'] ?? json
|
||||
|
||||
List<String>? _tagListFromJson(Object? raw) => stringListFromRaw(raw, mapKey: 'tag');
|
||||
|
||||
String? _stringOrNull(Object? value) {
|
||||
final string = value?.toString().trim();
|
||||
return string == null || string.isEmpty ? null : string;
|
||||
}
|
||||
|
||||
String _normalizedDisplayColorTags(String? transfer, String? primaries, String? matrix) =>
|
||||
[transfer, primaries, matrix].whereType<String>().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexRoleDto {
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
@@ -880,6 +889,61 @@ class PlexMappers {
|
||||
return mediaVersion(PlexMediaVersionDto.fromJson(json));
|
||||
}
|
||||
|
||||
static MediaDisplayCriteria? displayCriteriaFromJson(Map<String, dynamic>? media, Map<String, dynamic>? videoStream) {
|
||||
if (videoStream == null) return null;
|
||||
|
||||
final doviProfile = flexibleInt(videoStream['DOVIProfile']);
|
||||
final doviCompatibilityId = flexibleInt(videoStream['DOVIBLCompatID']);
|
||||
final hasDolbyVision = (doviProfile != null && doviProfile > 0) || flexibleBool(videoStream['DOVIPresent']);
|
||||
final transfer = _stringOrNull(videoStream['colorTrc']);
|
||||
final primaries = _stringOrNull(videoStream['colorPrimaries']);
|
||||
final matrix = _stringOrNull(videoStream['colorSpace']);
|
||||
final defaults = _defaultDisplayColorTags(
|
||||
isDolbyVision: hasDolbyVision,
|
||||
doviCompatibilityId: doviCompatibilityId,
|
||||
transfer: transfer,
|
||||
primaries: primaries,
|
||||
matrix: matrix,
|
||||
);
|
||||
final criteria = MediaDisplayCriteria.fromRaw(
|
||||
fps: videoStream['frameRate'],
|
||||
width: videoStream['width'] ?? media?['width'],
|
||||
height: videoStream['height'] ?? media?['height'],
|
||||
doviProfile: doviProfile,
|
||||
doviLevel: videoStream['DOVILevel'],
|
||||
doviCompatibilityId: doviCompatibilityId,
|
||||
transfer: transfer ?? defaults.transfer,
|
||||
primaries: primaries ?? defaults.primaries,
|
||||
matrix: matrix ?? defaults.matrix,
|
||||
);
|
||||
return criteria.isUsable ? criteria : null;
|
||||
}
|
||||
|
||||
static ({String? transfer, String? primaries, String? matrix}) _defaultDisplayColorTags({
|
||||
required bool isDolbyVision,
|
||||
int? doviCompatibilityId,
|
||||
String? transfer,
|
||||
String? primaries,
|
||||
String? matrix,
|
||||
}) {
|
||||
final colorTags = _normalizedDisplayColorTags(transfer, primaries, matrix);
|
||||
if (doviCompatibilityId == 4 || colorTags.contains('hlg') || colorTags.contains('arib')) {
|
||||
return (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc');
|
||||
}
|
||||
if (doviCompatibilityId == 1 ||
|
||||
doviCompatibilityId == 6 ||
|
||||
colorTags.contains('smpte2084') ||
|
||||
colorTags.contains('st2084') ||
|
||||
colorTags.contains('pq') ||
|
||||
colorTags.contains('bt2020')) {
|
||||
return (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc');
|
||||
}
|
||||
if (doviCompatibilityId == 2 || !isDolbyVision) {
|
||||
return (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709');
|
||||
}
|
||||
return (transfer: null, primaries: null, matrix: null);
|
||||
}
|
||||
|
||||
/// Map a parsed [PlexLibraryDto] into a [MediaLibrary].
|
||||
static MediaLibrary mediaLibrary(PlexLibraryDto dto) {
|
||||
return MediaLibrary(
|
||||
@@ -984,6 +1048,10 @@ MediaSourceInfo? plexMediaSourceInfoFromCacheJson(Map<String, dynamic> metadata,
|
||||
subtitleTracks: streams.subtitleTracks,
|
||||
chapters: const [],
|
||||
frameRate: streams.frameRate,
|
||||
displayCriteria: PlexMappers.displayCriteriaFromJson(
|
||||
selectedMedia is Map<String, dynamic> ? selectedMedia : null,
|
||||
streams.videoStream,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson(
|
||||
chapters: chapters,
|
||||
partId: part['id'] as int?,
|
||||
frameRate: streams.frameRate,
|
||||
displayCriteria: PlexMappers.displayCriteriaFromJson(media as Map<String, dynamic>?, streams.videoStream),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user