diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift index 3273dc16..465e7940 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -1,5 +1,5 @@ -import UIKit import Libmpv +import UIKit /// Protocol for receiving player events protocol MpvPlayerDelegate: AnyObject { @@ -27,7 +27,7 @@ private class MetalLayer: CAMetalLayer { if Thread.isMainThread { super.wantsExtendedDynamicRangeContent = newValue } else { - DispatchQueue.main.async { + DispatchQueue.main.sync { super.wantsExtendedDynamicRangeContent = newValue } } @@ -41,6 +41,7 @@ class MpvPlayerCore: NSObject { // MARK: - Properties private var metalLayer: MetalLayer? + private var containerView: UIView? // Container for proper EDR activation private var mpv: OpaquePointer? private weak var window: UIWindow? private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated) @@ -49,6 +50,10 @@ class MpvPlayerCore: NSObject { private(set) var isInitialized = false + // HDR settings + private var hdrEnabled = true // User preference for HDR + private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation + // MARK: - Initialization func initialize(in window: UIWindow) -> Bool { @@ -59,25 +64,36 @@ class MpvPlayerCore: NSObject { self.window = window + // Create container view for proper EDR activation + // EDR requires the CAMetalLayer to be in a UIView hierarchy, not just window.layer + let container = UIView(frame: window.bounds) + container.backgroundColor = .clear + container.isUserInteractionEnabled = false + // Create Metal layer for video rendering let layer = MetalLayer() - layer.frame = window.bounds + layer.frame = container.bounds layer.contentsScale = UIScreen.main.nativeScale layer.framebufferOnly = true layer.backgroundColor = UIColor.black.cgColor + container.layer.addSublayer(layer) + containerView = container metalLayer = layer - // Add Metal layer to WINDOW layer (behind Flutter's view controller) - window.layer.insertSublayer(layer, at: 0) + // Add container view to window (behind Flutter's root view controller) + window.insertSubview(container, at: 0) - print("[MpvPlayerCore] Metal layer added to window, frame: \(layer.frame)") + print( + "[MpvPlayerCore] Metal layer added to window via container view, frame: \(layer.frame)") // Initialize MPV with this Metal layer guard setupMpv() else { print("[MpvPlayerCore] Failed to setup MPV") layer.removeFromSuperlayer() + container.removeFromSuperview() metalLayer = nil + containerView = nil return false } @@ -100,9 +116,9 @@ class MpvPlayerCore: NSObject { // Logging #if DEBUG - checkError(mpv_request_log_messages(mpv, "info")) + checkError(mpv_request_log_messages(mpv, "info")) #else - checkError(mpv_request_log_messages(mpv, "warn")) + checkError(mpv_request_log_messages(mpv, "warn")) #endif // Set the Metal layer as the render target (must use local var for &) @@ -114,21 +130,29 @@ class MpvPlayerCore: NSObject { checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) // Initialize MPV let initResult = mpv_initialize(mpv) if initResult < 0 { - print("[MpvPlayerCore] mpv_initialize failed: \(String(cString: mpv_error_string(initResult)))") + print( + "[MpvPlayerCore] mpv_initialize failed: \(String(cString: mpv_error_string(initResult)))" + ) mpv_terminate_destroy(mpv) mpv = nil return false } // Set up wakeup callback for event handling - mpv_set_wakeup_callback(mpv, { ctx in - let core = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() - core.readEvents() - }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + mpv_set_wakeup_callback( + mpv, + { ctx in + let core = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + core.readEvents() + }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + + // Observe video-params/sig-peak for HDR detection + mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE) print("[MpvPlayerCore] MPV initialized successfully") return true @@ -171,9 +195,33 @@ class MpvPlayerCore: NSObject { func setProperty(_ name: String, value: String) { guard mpv != nil else { return } + + // Handle custom HDR toggle property + if name == "hdr-enabled" { + let enabled = value == "yes" || value == "true" || value == "1" + setHDREnabled(enabled) + return + } + mpv_set_property_string(mpv, name, value) } + /// Enable or disable HDR mode + func setHDREnabled(_ enabled: Bool) { + hdrEnabled = enabled + print("[MpvPlayerCore] HDR enabled: \(enabled)") + + // Update MPV's target-colorspace-hint + if mpv != nil { + mpv_set_property_string(mpv, "target-colorspace-hint", enabled ? "yes" : "no") + } + + // Re-evaluate EDR mode with current sig-peak + DispatchQueue.main.async { + self.updateEDRMode(sigPeak: self.lastSigPeak) + } + } + func getProperty(_ name: String) -> String? { guard mpv != nil else { return nil } let cstr = mpv_get_property_string(mpv, name) @@ -204,27 +252,27 @@ class MpvPlayerCore: NSObject { // MARK: - Visibility func setVisible(_ visible: Bool) { - guard let layer = metalLayer else { return } + guard let container = containerView else { return } if visible { - // Re-insert at the bottom of the window layer stack - layer.removeFromSuperlayer() - if let windowLayer = window?.layer { - windowLayer.insertSublayer(layer, at: 0) - } + // Re-insert at the bottom of the window view stack + container.removeFromSuperview() + window?.insertSubview(container, at: 0) } - layer.isHidden = !visible + container.isHidden = !visible print("[MpvPlayerCore] setVisible(\(visible))") } func updateFrame(_ frame: CGRect? = nil) { - guard let metalLayer = metalLayer else { return } + guard let metalLayer = metalLayer, let container = containerView else { return } if let frame = frame { - metalLayer.frame = frame + container.frame = frame + metalLayer.frame = container.bounds } else if let window = window { - metalLayer.frame = window.bounds + container.frame = window.bounds + metalLayer.frame = container.bounds } // Update drawable size for proper scaling @@ -234,7 +282,7 @@ class MpvPlayerCore: NSObject { height: metalLayer.frame.height * scale ) - print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") + print("[MpvPlayerCore] updateFrame: \(container.frame)") } // MARK: - Private Helpers @@ -244,7 +292,7 @@ class MpvPlayerCore: NSObject { // Build array of C strings for mpv_command var cargs: [UnsafeMutablePointer?] = ([cmd] + args).map { strdup($0) } - cargs.append(nil) // null-terminate + cargs.append(nil) // null-terminate defer { for ptr in cargs { free(ptr) @@ -300,7 +348,9 @@ class MpvPlayerCore: NSObject { if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { let msg = msgPtr.pointee if let prefix = msg.prefix, let level = msg.level, let text = msg.text { - print("[MPV:\(String(cString: prefix))] \(String(cString: level)): \(String(cString: text))", terminator: "") + print( + "[MPV:\(String(cString: prefix))] \(String(cString: level)): \(String(cString: text))", + terminator: "") } } @@ -339,11 +389,43 @@ class MpvPlayerCore: NSObject { break } + // Handle sig-peak for HDR/EDR activation + if name == "video-params/sig-peak", let sigPeak = value as? Double { + lastSigPeak = sigPeak + DispatchQueue.main.async { + self.updateEDRMode(sigPeak: sigPeak) + } + } + DispatchQueue.main.async { self.delegate?.onPropertyChange(name: name, value: value) } } + // MARK: - HDR/EDR Support + + private func updateEDRMode(sigPeak: Double) { + guard let layer = metalLayer else { return } + + // Check if screen supports EDR (iOS 16+) + var edrHeadroom: CGFloat = 1.0 + if #available(iOS 16.0, *) { + edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 + } + + let isHDRContent = sigPeak > 1.0 + let screenSupportsEDR = edrHeadroom > 1.0 + let shouldEnableEDR = hdrEnabled && isHDRContent && screenSupportsEDR + + if #available(iOS 16.0, *) { + layer.wantsExtendedDynamicRangeContent = shouldEnableEDR + } + + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" + ) + } + private func convertNode(_ node: mpv_node) -> Any? { switch node.format { case MPV_FORMAT_STRING: @@ -373,7 +455,8 @@ class MpvPlayerCore: NSObject { var dict = [String: Any]() for i in 0.. // Create player player = Player(); - // Configure player properties - await player!.setProperty( - 'target-colorspace-hint', - 'yes', - ); // Enable HDR passthrough await player!.setProperty('sub-ass', 'yes'); // Enable libass await player!.setProperty('sub-fonts-dir', 'assets'); await player!.setProperty('sub-font', 'Go Noto Current-Regular'); @@ -303,6 +298,12 @@ class VideoPlayerScreenState extends State await player!.setProperty('audio-exclusive', 'yes'); } + // HDR is controlled via custom hdr-enabled property on iOS/macOS + if (Platform.isIOS || Platform.isMacOS) { + final enableHDR = settingsService.getEnableHDR(); + await player!.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no'); + } + // Apply audio sync offset final audioSyncOffset = settingsService.getAudioSyncOffset(); if (audioSyncOffset != 0) { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5d52f291..e4268915 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -18,6 +18,7 @@ class SettingsService { static const String _keyKeyboardShortcuts = 'keyboard_shortcuts'; static const String _keyKeyboardHotkeys = 'keyboard_hotkeys'; static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding'; + static const String _keyEnableHDR = 'enable_hdr'; static const String _keyPreferredVideoCodec = 'preferred_video_codec'; static const String _keyPreferredAudioCodec = 'preferred_audio_codec'; static const String _keyLibraryDensity = 'library_density'; @@ -105,6 +106,15 @@ class SettingsService { true; // Default enabled } + // HDR (High Dynamic Range) + Future setEnableHDR(bool enabled) async { + await _prefs.setBool(_keyEnableHDR, enabled); + } + + bool getEnableHDR() { + return _prefs.getBool(_keyEnableHDR) ?? true; // Default enabled + } + // Preferred Video Codec Future setPreferredVideoCodec(String codec) async { await _prefs.setString(_keyPreferredVideoCodec, codec); @@ -852,6 +862,7 @@ class SettingsService { _prefs.remove(_keyKeyboardShortcuts), _prefs.remove(_keyKeyboardHotkeys), _prefs.remove(_keyEnableHardwareDecoding), + _prefs.remove(_keyEnableHDR), _prefs.remove(_keyPreferredVideoCodec), _prefs.remove(_keyPreferredAudioCodec), _prefs.remove(_keyLibraryDensity), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 19764c40..9734eaba 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; @@ -102,12 +104,32 @@ class _VideoSettingsSheetState extends State { _SettingsView _currentView = _SettingsView.menu; late int _audioSyncOffset; late int _subtitleSyncOffset; + bool _enableHDR = true; @override void initState() { super.initState(); _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; + _loadHDRSetting(); + } + + Future _loadHDRSetting() async { + final settings = await SettingsService.getInstance(); + setState(() { + _enableHDR = settings.getEnableHDR(); + }); + } + + Future _toggleHDR() async { + final newValue = !_enableHDR; + final settings = await SettingsService.getInstance(); + await settings.setEnableHDR(newValue); + setState(() { + _enableHDR = newValue; + }); + // Apply to player immediately + await widget.player.setProperty('hdr-enabled', newValue ? 'yes' : 'no'); } void _navigateTo(_SettingsView view) { @@ -252,6 +274,22 @@ class _VideoSettingsSheetState extends State { onTap: () => _navigateTo(_SettingsView.subtitleSync), ), + // HDR Toggle (iOS and macOS only) + if (Platform.isIOS || Platform.isMacOS) + ListTile( + leading: Icon( + Icons.hdr_strong, + color: _enableHDR ? Colors.amber : Colors.white70, + ), + title: const Text('HDR', style: TextStyle(color: Colors.white)), + trailing: Switch( + value: _enableHDR, + onChanged: (_) => _toggleHDR(), + activeColor: Colors.amber, + ), + onTap: _toggleHDR, + ), + // Audio Output Device (Desktop only) if (isDesktop) StreamBuilder( diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 36f71f91..87849276 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -48,6 +48,10 @@ class MpvPlayerCore: NSObject { private(set) var isInitialized = false + // HDR settings + private var hdrEnabled = true // User preference for HDR + private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation + // MARK: - Initialization func initialize(in window: NSWindow) -> Bool { @@ -118,6 +122,7 @@ class MpvPlayerCore: NSObject { checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) // Initialize MPV let initResult = mpv_initialize(mpv) @@ -134,6 +139,9 @@ class MpvPlayerCore: NSObject { core.readEvents() }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + // Observe video-params/sig-peak for HDR detection + mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE) + print("[MpvPlayerCore] MPV initialized successfully") return true } @@ -142,9 +150,33 @@ class MpvPlayerCore: NSObject { func setProperty(_ name: String, value: String) { guard mpv != nil else { return } + + // Handle custom HDR toggle property + if name == "hdr-enabled" { + let enabled = value == "yes" || value == "true" || value == "1" + setHDREnabled(enabled) + return + } + mpv_set_property_string(mpv, name, value) } + /// Enable or disable HDR mode + func setHDREnabled(_ enabled: Bool) { + hdrEnabled = enabled + print("[MpvPlayerCore] HDR enabled: \(enabled)") + + // Update MPV's target-colorspace-hint + if mpv != nil { + mpv_set_property_string(mpv, "target-colorspace-hint", enabled ? "yes" : "no") + } + + // Re-evaluate EDR mode with current sig-peak + DispatchQueue.main.async { + self.updateEDRMode(sigPeak: self.lastSigPeak) + } + } + func getProperty(_ name: String) -> String? { guard mpv != nil else { return nil } let cstr = mpv_get_property_string(mpv, name) @@ -312,11 +344,41 @@ class MpvPlayerCore: NSObject { break } + // Handle sig-peak for HDR/EDR activation + if name == "video-params/sig-peak", let sigPeak = value as? Double { + lastSigPeak = sigPeak + DispatchQueue.main.async { + self.updateEDRMode(sigPeak: sigPeak) + } + } + DispatchQueue.main.async { self.delegate?.onPropertyChange(name: name, value: value) } } + // MARK: - HDR/EDR Support + + private func updateEDRMode(sigPeak: Double) { + guard let layer = metalLayer else { return } + + // Check if screen supports EDR + var edrHeadroom: CGFloat = 1.0 + if let screen = window?.screen ?? NSScreen.main { + edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue + } + + let isHDRContent = sigPeak > 1.0 + let screenSupportsEDR = edrHeadroom > 1.0 + let shouldEnableEDR = hdrEnabled && isHDRContent && screenSupportsEDR + + layer.wantsExtendedDynamicRangeContent = shouldEnableEDR + + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" + ) + } + private func convertNode(_ node: mpv_node) -> Any? { switch node.format { case MPV_FORMAT_STRING: