fix(tvos): prime display criteria from metadata
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,18 @@ func safeString(_ cstr: UnsafePointer<CChar>) -> 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<UInt64> = [
|
||||
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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user