From 12b006ee0ca6515be51ce7d8ebbfd0473a199626 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 14 May 2026 19:51:33 +0200 Subject: [PATCH] fix(tvos): prime display criteria from metadata --- ios/Runner/MpvPlayer/MpvPlayerCore.swift | 257 +++++++++++++++--- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 72 +++++ lib/media/media_display_criteria.dart | 83 ++++++ lib/media/media_source_info.dart | 3 + lib/mpv/player/player.dart | 5 + lib/mpv/player/player_base.dart | 4 + lib/mpv/player/player_native.dart | 8 + .../parts/episode_navigation.dart | 1 + .../video_player/parts/playback_start.dart | 2 + lib/services/file_info_parser.dart | 2 +- lib/services/jellyfin_media_info_mapper.dart | 70 +++++ lib/services/plex_mappers.dart | 68 +++++ lib/services/plex_playback_mapper.dart | 1 + .../apple/MpvPlayer/MpvPlayerCoreBase.swift | 118 +++++++- test/services/jellyfin_media_info_test.dart | 53 ++++ test/services/plex_playback_mapper_test.dart | 82 ++++++ 16 files changed, 788 insertions(+), 41 deletions(-) create mode 100644 lib/media/media_display_criteria.dart diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 0320df91..bd07f2d8 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -13,6 +13,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { private var mainBlankView: UIView? private var isVisible = false private var isDisposed = false + private var activeDisplayCriteriaKey: String? var isPipStarting = false @@ -99,7 +100,7 @@ class MpvPlayerCore: MpvPlayerCoreBase { } private func refreshExternalDisplayAttachment() { - guard let containerView else { return } + guard containerView != nil else { return } let externalSuperview = externalVideoSuperview @@ -208,55 +209,236 @@ class MpvPlayerCore: MpvPlayerCoreBase { ) } + @discardableResult override func updateDisplayCriteria( doviProfile: Int64, doviLevel: Int64, + doviCompatibilityId: Int64?, fps: Double, width: Int32, height: Int32, - sigPeak: Double - ) { + sigPeak: Double, + gamma: String?, + primaries: String?, + colorMatrix: String? + ) -> Bool { #if os(tvOS) - guard let window = containerView?.window ?? self.window else { return } + guard let window = containerView?.window ?? self.window else { return false } let displayManager = window.avDisplayManager - guard displayManager.isDisplayCriteriaMatchingEnabled else { return } + + if width <= 0 || height <= 0 { + clearDisplayCriteria(displayManager, reason: "no video dimensions") + return false + } let refreshRate = Float(fps > 0 ? fps : 0) + let sourceHasDolbyVision = doviProfile > 0 + guard sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil else { + clearDisplayCriteria(displayManager, reason: "no display metadata") + return false + } - if doviProfile > 0, width > 0, height > 0, #available(tvOS 17.0, *) { - // Profile 8.x always carries a compatibility id; profile 5 has none. - // We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8 - // because mpv does not expose the compat id and that's by far the - // most common case (and matches the user's reported content). - let compat: UInt8 = doviProfile == 8 ? 1 : 0 - if let fd = Self.makeDolbyVisionFormatDescription( + let sourceBaseRange = Self.resolveBaseDisplayDynamicRange( + sigPeak: sigPeak, + gamma: gamma, + primaries: primaries, + colorMatrix: colorMatrix, + doviCompatibilityId: doviCompatibilityId + ) + let sourceRange: DisplayDynamicRange = sourceHasDolbyVision ? .dolbyVision : sourceBaseRange + let displayRange: DisplayDynamicRange + if sourceHasDolbyVision { + displayRange = Self.supportedDolbyVisionDisplayDynamicRange(fallback: sourceBaseRange) + } else { + displayRange = Self.supportedDisplayDynamicRange(for: sourceBaseRange) + } + guard displayManager.isDisplayCriteriaMatchingEnabled else { + clearDisplayCriteria(displayManager, reason: "matching disabled") + return false + } + guard #available(tvOS 17.0, *) else { + clearDisplayCriteria(displayManager, reason: "display criteria unavailable") + return false + } + + guard + let formatDescription = Self.makeDisplayFormatDescription( + dynamicRange: displayRange, width: width, height: height, - profile: UInt8(truncatingIfNeeded: doviProfile), - level: UInt8(truncatingIfNeeded: doviLevel), - compatibility: compat) - { - let criteria = AVDisplayCriteria(refreshRate: refreshRate, formatDescription: fd) - displayManager.preferredDisplayCriteria = criteria - print( - "[MpvPlayerCore] preferredDisplayCriteria set to Dolby Vision (profile: \(doviProfile), level: \(doviLevel), fps: \(refreshRate), \(width)x\(height))" - ) - return - } - print("[MpvPlayerCore] Failed to synthesize DV CMVideoFormatDescription; clearing criteria") + doviProfile: doviProfile, + doviLevel: doviLevel, + doviCompatibilityId: doviCompatibilityId) + else { + clearDisplayCriteria(displayManager, reason: "format description failed") + return false } - // Non-DV content (HDR10 / SDR) or DV FD synthesis failed: clear the - // hint and let tvOS auto-pick from the AVSampleBufferDisplayLayer's - // actual sample-buffer attachments (BT.2020 + PQ for HDR10). - if displayManager.preferredDisplayCriteria != nil { - displayManager.preferredDisplayCriteria = nil - print("[MpvPlayerCore] preferredDisplayCriteria cleared (sigPeak: \(sigPeak))") - } + let criteriaKey = + "\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)" + if activeDisplayCriteriaKey == criteriaKey { return true } + + displayManager.preferredDisplayCriteria = AVDisplayCriteria( + refreshRate: refreshRate, + formatDescription: formatDescription + ) + activeDisplayCriteriaKey = criteriaKey + print( + "[MpvPlayerCore] preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))" + ) + return true + #else + return false #endif } #if os(tvOS) + private enum DisplayDynamicRange: String { + case sdr = "SDR" + case hdr10 = "HDR10" + case hlg = "HLG" + case dolbyVision = "Dolby Vision" + } + + private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) { + if activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil { + displayManager.preferredDisplayCriteria = nil + activeDisplayCriteriaKey = nil + print("[MpvPlayerCore] preferredDisplayCriteria cleared (\(reason))") + } + } + + private static func resolveBaseDisplayDynamicRange( + sigPeak: Double, + gamma: String?, + primaries: String?, + colorMatrix: String?, + doviCompatibilityId: Int64? + ) -> DisplayDynamicRange { + let normalizedGamma = normalizeColorTag(gamma) + let normalizedPrimaries = normalizeColorTag(primaries) + let normalizedColorMatrix = normalizeColorTag(colorMatrix) + + if normalizedGamma.contains("hlg") || normalizedGamma.contains("arib") { + return .hlg + } + if normalizedGamma.contains("pq") || normalizedGamma.contains("smpte2084") + || normalizedGamma.contains("st2084") || sigPeak > 1.0 + || normalizedPrimaries.contains("bt2020") || normalizedColorMatrix.contains("bt2020") + { + return .hdr10 + } + switch doviCompatibilityId { + case 1, 6: + return .hdr10 + case 4: + return .hlg + case 2: + return .sdr + default: + break + } + return .sdr + } + + private static func normalizeColorTag(_ value: String?) -> String { + value?.lowercased().filter { $0.isLetter || $0.isNumber } ?? "" + } + + private static func supportedDolbyVisionDisplayDynamicRange( + fallback: DisplayDynamicRange + ) -> DisplayDynamicRange { + let availableModes = AVPlayer.availableHDRModes + if availableModes.contains(.dolbyVision) { return .dolbyVision } + return supportedDisplayDynamicRange(for: fallback) + } + + private static func supportedDisplayDynamicRange(for range: DisplayDynamicRange) -> DisplayDynamicRange { + let availableModes = AVPlayer.availableHDRModes + switch range { + case .dolbyVision: + if availableModes.contains(.dolbyVision) { return .dolbyVision } + if availableModes.contains(.hdr10) { return .hdr10 } + if availableModes.contains(.hlg) { return .hlg } + return .sdr + case .hdr10: + return availableModes.contains(.hdr10) ? .hdr10 : .sdr + case .hlg: + return availableModes.contains(.hlg) ? .hlg : .sdr + case .sdr: + return .sdr + } + } + + private static func makeDisplayFormatDescription( + dynamicRange: DisplayDynamicRange, + width: Int32, + height: Int32, + doviProfile: Int64, + doviLevel: Int64, + doviCompatibilityId: Int64? + ) -> CMVideoFormatDescription? { + if dynamicRange == .dolbyVision { + // Profile 8.x always carries a compatibility id; profile 5 has none. + // We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8 + // because mpv does not expose the compat id and that's by far the + // most common case. + let fallbackCompat: Int64 = doviProfile == 8 ? 1 : 0 + let compat = UInt8(truncatingIfNeeded: doviCompatibilityId ?? fallbackCompat) + return makeDolbyVisionFormatDescription( + width: width, + height: height, + profile: UInt8(truncatingIfNeeded: doviProfile), + level: UInt8(truncatingIfNeeded: doviLevel), + compatibility: compat + ) + } + + let extensions: [CFString: Any] + switch dynamicRange { + case .hdr10: + extensions = [ + kCMFormatDescriptionExtension_ColorPrimaries: + kCMFormatDescriptionColorPrimaries_ITU_R_2020, + kCMFormatDescriptionExtension_TransferFunction: + kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ, + kCMFormatDescriptionExtension_YCbCrMatrix: + kCMFormatDescriptionYCbCrMatrix_ITU_R_2020, + ] + case .hlg: + extensions = [ + kCMFormatDescriptionExtension_ColorPrimaries: + kCMFormatDescriptionColorPrimaries_ITU_R_2020, + kCMFormatDescriptionExtension_TransferFunction: + kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG, + kCMFormatDescriptionExtension_YCbCrMatrix: + kCMFormatDescriptionYCbCrMatrix_ITU_R_2020, + ] + case .sdr: + extensions = [ + kCMFormatDescriptionExtension_ColorPrimaries: + kCMFormatDescriptionColorPrimaries_ITU_R_709_2, + kCMFormatDescriptionExtension_TransferFunction: + kCMFormatDescriptionTransferFunction_ITU_R_709_2, + kCMFormatDescriptionExtension_YCbCrMatrix: + kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2, + ] + case .dolbyVision: + return nil + } + + var fd: CMVideoFormatDescription? + let status = CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + codecType: kCMVideoCodecType_HEVC, + width: width, + height: height, + extensions: extensions as CFDictionary, + formatDescriptionOut: &fd + ) + return status == noErr ? fd : nil + } + /// Build a synthetic 'dvh1' `CMVideoFormatDescription` from the Dolby Vision /// metadata mpv exposes. Used solely as a hint object for /// `AVDisplayCriteria(refreshRate:formatDescription:)` — it is never @@ -288,14 +470,13 @@ class MpvPlayerCore: MpvPlayerCoreBase { dovi[3] = UInt8(flags & 0xff) dovi[4] = (compatibility & 0x0f) << 4 - // CoreMedia does not export a typed - // `kCMFormatDescriptionExtension_DolbyVision…` constant. The well-known - // CFString key is the four-char box name VideoToolbox/AVFoundation - // expect (same key FFmpeg writes in 0002-videotoolbox-add-dolby-vision-hevc-format.patch). + // CoreMedia carries codec-specific boxes under + // kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms. let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString + let atoms: [CFString: Any] = [recordKey: Data(dovi) as CFData] let extensions: [CFString: Any] = [ - recordKey: Data(dovi) as CFData, + kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: atoms as CFDictionary, kCMFormatDescriptionExtension_ColorPrimaries: kCMFormatDescriptionColorPrimaries_ITU_R_2020, kCMFormatDescriptionExtension_TransferFunction: @@ -329,7 +510,9 @@ class MpvPlayerCore: MpvPlayerCoreBase { // dealloc (the plugin sets playerCore = nil right after this call // returns), leaving the link stuck at the last clip's refresh rate. updateDisplayCriteria( - doviProfile: 0, doviLevel: 0, fps: 0, width: 0, height: 0, sigPeak: 0) + doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil, + fps: 0, width: 0, height: 0, sigPeak: 0, + gamma: nil, primaries: nil, colorMatrix: nil) NotificationCenter.default.removeObserver(self) #if os(iOS) diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index a09fc46f..ae2f886c 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -81,6 +81,8 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS handleObserveProperty(call: call, result: result) case "command": handleCommand(call: call, result: result) + case "setDisplayCriteria": + handleSetDisplayCriteria(call: call, result: result) case "setVisible": handleSetVisible(call: call, result: result) case "isInitialized": @@ -363,6 +365,76 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS } } + private func handleSetDisplayCriteria(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing arguments", details: nil)) + return + } + + guard let core = playerCore else { + result(nil) + return + } + + guard let raw = args["criteria"] as? [String: Any] else { + DispatchQueue.main.async { + core.setServerDisplayCriteria(nil) + result(nil) + } + return + } + + let criteria = ServerDisplayCriteria( + doviProfile: int64Value(raw["doviProfile"]) ?? 0, + doviLevel: int64Value(raw["doviLevel"]) ?? 0, + doviCompatibilityId: int64Value(raw["doviCompatibilityId"]), + fps: doubleValue(raw["fps"]) ?? 0, + width: Int32(truncatingIfNeeded: int64Value(raw["width"]) ?? 0), + height: Int32(truncatingIfNeeded: int64Value(raw["height"]) ?? 0), + gamma: stringValue(raw["transfer"]), + primaries: stringValue(raw["primaries"]), + colorMatrix: stringValue(raw["matrix"]) + ) + DispatchQueue.main.async { + core.setServerDisplayCriteria(criteria) + result(nil) + } + } + + private func int64Value(_ value: Any?) -> Int64? { + switch value { + case let value as Int64: + return value + case let value as Int: + return Int64(value) + case let value as NSNumber: + return value.int64Value + case let value as String: + return Int64(value) + default: + return nil + } + } + + private func doubleValue(_ value: Any?) -> Double? { + switch value { + case let value as Double: + return value + case let value as NSNumber: + return value.doubleValue + case let value as String: + return Double(value) + default: + return nil + } + } + + private func stringValue(_ value: Any?) -> String? { + guard let value else { return nil } + let string = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines) + return string.isEmpty ? nil : string + } + // MARK: - Helpers private func findKeyWindow() -> UIWindow? { diff --git a/lib/media/media_display_criteria.dart b/lib/media/media_display_criteria.dart new file mode 100644 index 00000000..b36b8a54 --- /dev/null +++ b/lib/media/media_display_criteria.dart @@ -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 toJson() { + final json = {}; + 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; diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 7f18b722..67aeeeeb 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -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 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, diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index c9ad1931..d5b82ab0 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -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 command(List args); + /// Prime native display matching from server metadata before the decoder + /// emits stream properties. Unsupported platforms ignore this. + Future setDisplayCriteria(MediaDisplayCriteria? criteria); + /// Configure subtitle fonts for libass rendering. /// /// Extracts a comprehensive Unicode font (Go Noto) to the cache directory diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 10c3c4da..901c07d4 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -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 setDisplayCriteria(MediaDisplayCriteria? criteria) async {} + @override Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async { if (_disposed) return false; diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index be780115..6ce52099 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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 setDisplayCriteria(MediaDisplayCriteria? criteria) async { + if (disposed || !Platform.isIOS) return; + await _ensureInitialized(); + await invoke('setDisplayCriteria', {'criteria': criteria?.toJson()}); + } + @override Future setLogLevel(String level) async { if (disposed) return; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 298cdfb0..467f51cf 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -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, diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 04e16910..4f3a7ae1 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -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'); diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 6043b7c5..3595eca5 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -199,7 +199,7 @@ class PlexFileInfoStreamReader implements FileInfoStreamReader { @override double? frameRateOf(Map videoStream) { - return (videoStream['frameRate'] as num?)?.toDouble(); + return flexibleDouble(videoStream['frameRate']); } } diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 5d2ee508..62e27825 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -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 source, Map? 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().join(' ').toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + List _withDefaultAudioSelection(List tracks, int? defaultStreamIndex) { if (defaultStreamIndex == null) return tracks; return [ diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 8154224f..ac32662c 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -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? _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().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? media, Map? 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 metadata, 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 ed0336ce..14ab87c5 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -59,6 +59,7 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( chapters: chapters, partId: part['id'] as int?, frameRate: streams.frameRate, + displayCriteria: PlexMappers.displayCriteriaFromJson(media as Map?, streams.videoStream), ); } } diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index ccbb51f5..cab76a81 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -61,6 +61,18 @@ func safeString(_ cstr: UnsafePointer) -> String { return String(buffer.map { Character(Unicode.Scalar($0)) }) } +struct ServerDisplayCriteria { + let doviProfile: Int64 + let doviLevel: Int64 + let doviCompatibilityId: Int64? + let fps: Double + let width: Int32 + let height: Int32 + let gamma: String? + let primaries: String? + let colorMatrix: String? +} + class MpvPlayerCoreBase: NSObject { weak var delegate: MpvPlayerDelegate? @@ -80,6 +92,10 @@ class MpvPlayerCoreBase: NSObject { private var cachedDoviProfile: Int64 = 0 private var cachedDoviLevel: Int64 = 0 private var cachedContainerFps: Double = 0 + private var cachedVideoGamma: String? + private var cachedVideoPrimaries: String? + private var cachedVideoColorMatrix: String? + private var serverDisplayCriteriaActive = false var hdrEnabled: Bool { cacheLock.lock() defer { cacheLock.unlock() } @@ -117,6 +133,9 @@ class MpvPlayerCoreBase: NSObject { private static let internalDoviProfileObserverId: UInt64 = UInt64.max - 4 private static let internalDoviLevelObserverId: UInt64 = UInt64.max - 5 private static let internalContainerFpsObserverId: UInt64 = UInt64.max - 6 + private static let internalVideoGammaObserverId: UInt64 = UInt64.max - 7 + private static let internalVideoPrimariesObserverId: UInt64 = UInt64.max - 8 + private static let internalVideoColorMatrixObserverId: UInt64 = UInt64.max - 9 private static let internalObserverIds: Set = [ internalSigPeakObserverId, internalWidthObserverId, @@ -124,6 +143,9 @@ class MpvPlayerCoreBase: NSObject { internalDoviProfileObserverId, internalDoviLevelObserverId, internalContainerFpsObserverId, + internalVideoGammaObserverId, + internalVideoPrimariesObserverId, + internalVideoColorMatrixObserverId, ] let queue = DispatchQueue(label: "mpv", qos: .userInitiated) @@ -156,37 +178,103 @@ class MpvPlayerCoreBase: NSObject { func updateEDRMode(sigPeak: Double) {} + @discardableResult func updateDisplayCriteria( doviProfile: Int64, doviLevel: Int64, + doviCompatibilityId: Int64?, fps: Double, width: Int32, height: Int32, - sigPeak: Double - ) {} + sigPeak: Double, + gamma: String?, + primaries: String?, + colorMatrix: String? + ) -> Bool { false } func scheduleDisplayCriteriaUpdate() { cacheLock.lock() + if serverDisplayCriteriaActive { + cacheLock.unlock() + return + } let profile = cachedDoviProfile let level = cachedDoviLevel let fps = cachedContainerFps let width = Int32(cachedWidth) let height = Int32(cachedHeight) let sigPeak = cachedLastSigPeak + let gamma = cachedVideoGamma + let primaries = cachedVideoPrimaries + let colorMatrix = cachedVideoColorMatrix cacheLock.unlock() DispatchQueue.main.async { [weak self] in self?.updateDisplayCriteria( doviProfile: profile, doviLevel: level, + doviCompatibilityId: nil, fps: fps, width: width, height: height, - sigPeak: sigPeak + sigPeak: sigPeak, + gamma: gamma, + primaries: primaries, + colorMatrix: colorMatrix ) } } + func setServerDisplayCriteria(_ criteria: ServerDisplayCriteria?) { + cacheLock.lock() + serverDisplayCriteriaActive = criteria != nil + cacheLock.unlock() + + let apply = { [weak self] in + guard let self else { return } + guard let criteria else { + _ = self.updateDisplayCriteria( + doviProfile: 0, + doviLevel: 0, + doviCompatibilityId: nil, + fps: 0, + width: 0, + height: 0, + sigPeak: 0, + gamma: nil, + primaries: nil, + colorMatrix: nil + ) + return + } + + let applied = self.updateDisplayCriteria( + doviProfile: criteria.doviProfile, + doviLevel: criteria.doviLevel, + doviCompatibilityId: criteria.doviCompatibilityId, + fps: criteria.fps, + width: criteria.width, + height: criteria.height, + sigPeak: 0, + gamma: criteria.gamma, + primaries: criteria.primaries, + colorMatrix: criteria.colorMatrix + ) + if !applied { + self.cacheLock.lock() + self.serverDisplayCriteriaActive = false + self.cacheLock.unlock() + self.scheduleDisplayCriteriaUpdate() + } + } + + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } + func setupMpv() -> Bool { #if os(macOS) guard let renderLayer = metalLayer else { return false } @@ -246,6 +334,11 @@ class MpvPlayerCoreBase: NSObject { mpv_observe_property( mpv, Self.internalContainerFpsObserverId, "container-fps", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING) + mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING) + mpv_observe_property( + mpv, Self.internalVideoColorMatrixObserverId, + "video-params/colormatrix", MPV_FORMAT_STRING) return true } @@ -444,6 +537,10 @@ class MpvPlayerCoreBase: NSObject { cachedDoviLevel = 0 cachedContainerFps = 0 cachedLastSigPeak = 0 + cachedVideoGamma = nil + cachedVideoPrimaries = nil + cachedVideoColorMatrix = nil + serverDisplayCriteriaActive = false cacheLock.unlock() let mpvHandle = mpv @@ -780,6 +877,21 @@ class MpvPlayerCoreBase: NSObject { cachedContainerFps = (value as? Double) ?? 0 cacheLock.unlock() scheduleDisplayCriteriaUpdate() + case "video-params/gamma": + cacheLock.lock() + cachedVideoGamma = value as? String + cacheLock.unlock() + scheduleDisplayCriteriaUpdate() + case "video-params/primaries": + cacheLock.lock() + cachedVideoPrimaries = value as? String + cacheLock.unlock() + scheduleDisplayCriteriaUpdate() + case "video-params/colormatrix": + cacheLock.lock() + cachedVideoColorMatrix = value as? String + cacheLock.unlock() + scheduleDisplayCriteriaUpdate() case "width", "height": scheduleDisplayCriteriaUpdate() default: diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 4d938dca..af5b0a4d 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -85,6 +85,59 @@ void main() { expect(sub.key, '/Videos/src-1/Subtitles/3/Stream.srt'); }); + test('maps display criteria from Jellyfin video stream metadata', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-1', + 'MediaStreams': [ + { + 'Index': 0, + 'Type': 'Video', + 'RealFrameRate': 23.976025, + 'Width': 3840, + 'Height': 2160, + 'VideoRangeType': 'DOVIWithHDR10Plus', + 'DvProfile': 8, + 'DvLevel': 10, + 'DvBlSignalCompatibilityId': 1, + }, + ], + }); + + final criteria = info.displayCriteria; + expect(criteria, isNotNull); + expect(criteria!.fps, closeTo(23.976, 0.001)); + expect(criteria.width, 3840); + expect(criteria.height, 2160); + expect(criteria.doviProfile, 8); + expect(criteria.doviLevel, 10); + expect(criteria.doviCompatibilityId, 1); + expect(criteria.transfer, 'smpte2084'); + expect(criteria.primaries, 'bt2020'); + expect(criteria.matrix, 'bt2020nc'); + }); + + test('fills missing HDR color tags from partial Jellyfin transfer metadata', () { + final info = jellyfinMediaSourceToMediaSourceInfo({ + 'Id': 'src-1', + 'MediaStreams': [ + { + 'Index': 0, + 'Type': 'Video', + 'RealFrameRate': 23.976, + 'Width': 3840, + 'Height': 2160, + 'ColorTransfer': 'smpte2084', + }, + ], + }); + + final criteria = info.displayCriteria; + expect(criteria, isNotNull); + expect(criteria!.transfer, 'smpte2084'); + expect(criteria.primaries, 'bt2020'); + expect(criteria.matrix, 'bt2020nc'); + }); + test('handles missing MediaStreams gracefully', () { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x'}); expect(info.audioTracks, isEmpty); diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index bca2618d..dc476cf1 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -47,6 +47,88 @@ void main() { expect(result.mediaInfo?.frameRate, 23.976); expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng'); }); + + test('maps server display criteria from selected video stream', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 1, + 'width': 3840, + 'height': 2160, + 'videoResolution': '4k', + 'Part': [ + { + 'id': 10, + 'key': '/library/parts/10/file.mkv', + 'accessible': 1, + 'exists': 1, + 'Stream': [ + { + 'streamType': 1, + 'frameRate': '23.976', + 'DOVIProfile': '7', + 'DOVILevel': '6', + 'DOVIBLCompatID': '6', + 'colorTrc': 'smpte2084', + 'colorPrimaries': 'bt2020', + 'colorSpace': 'bt2020nc', + }, + ], + }, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: null, + ); + + final criteria = result.mediaInfo?.displayCriteria; + expect(criteria, isNotNull); + expect(criteria!.fps, closeTo(23.976, 0.001)); + expect(criteria.width, 3840); + expect(criteria.height, 2160); + expect(criteria.doviProfile, 7); + expect(criteria.doviLevel, 6); + expect(criteria.doviCompatibilityId, 6); + expect(criteria.transfer, 'smpte2084'); + expect(criteria.primaries, 'bt2020'); + expect(criteria.matrix, 'bt2020nc'); + }); + + test('fills missing HDR color tags from partial Plex transfer metadata', () { + final result = parsePlexVideoPlaybackDataFromJson( + { + 'Media': [ + { + 'id': 1, + 'width': 3840, + 'height': 2160, + 'Part': [ + { + 'id': 10, + 'key': '/library/parts/10/file.mkv', + 'accessible': 1, + 'exists': 1, + 'Stream': [ + {'streamType': 1, 'frameRate': 23.976, 'colorTrc': 'smpte2084'}, + ], + }, + ], + }, + ], + }, + baseUrl: 'http://plex:32400', + token: null, + ); + + final criteria = result.mediaInfo?.displayCriteria; + expect(criteria, isNotNull); + expect(criteria!.transfer, 'smpte2084'); + expect(criteria.primaries, 'bt2020'); + expect(criteria.matrix, 'bt2020nc'); + }); }); group('parsePlexFileInfoFromJson', () {