From c1e2bc36ea02a2204ad384950a274a1baefe2cb9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:15:50 +0100 Subject: [PATCH] refactor: share Apple MPV core and media helpers --- .../Shared/MpvPlayer/MpvPlayerCoreBase.swift | 515 ++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/MpvPlayer/MpvPlayerCore.swift | 720 +++-------------- lib/mixins/mounted_set_state_mixin.dart | 10 + lib/mixins/server_bound_media_mixin.dart | 21 + lib/mpv/player/player_base.dart | 1 + lib/screens/media_detail_screen.dart | 765 +++++++++--------- lib/screens/season_detail_screen.dart | 48 +- lib/services/plex_client.dart | 37 +- lib/utils/provider_extensions.dart | 9 +- .../desktop_video_controls.dart | 166 +--- .../models/track_controls_state.dart | 75 ++ .../video_controls/sheets/chapter_sheet.dart | 15 +- .../video_controls/sheets/queue_sheet.dart | 7 +- .../video_controls/video_controls.dart | 92 +-- .../video_controls/widgets/content_strip.dart | 34 +- .../widgets/track_chapter_controls.dart | 111 +-- macos/Runner.xcodeproj/project.pbxproj | 4 + macos/Runner/MpvPlayer/MpvPlayerCore.swift | 672 +++------------ 19 files changed, 1377 insertions(+), 1929 deletions(-) create mode 100644 apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift create mode 100644 lib/mixins/mounted_set_state_mixin.dart create mode 100644 lib/mixins/server_bound_media_mixin.dart create mode 100644 lib/widgets/video_controls/models/track_controls_state.dart diff --git a/apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift b/apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift new file mode 100644 index 00000000..1b1342b9 --- /dev/null +++ b/apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift @@ -0,0 +1,515 @@ +import Foundation +import Libmpv +import QuartzCore + +#if os(iOS) +import UIKit +#elseif os(macOS) +import Cocoa +#endif + +protocol MpvPlayerDelegate: AnyObject { + func onPropertyChange(name: String, value: Any?) + func onEvent(name: String, data: [String: Any]?) +} + +// Workaround for MoltenVK problems that cause flicker. +// https://github.com/mpv-player/mpv/pull/13651 +class MpvMetalLayer: CAMetalLayer { + override var drawableSize: CGSize { + get { super.drawableSize } + set { + if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { + super.drawableSize = newValue + } + } + } + + #if os(iOS) + @available(iOS 16.0, *) + override var wantsExtendedDynamicRangeContent: Bool { + get { super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.sync { + super.wantsExtendedDynamicRangeContent = newValue + } + } + } + } + #elseif os(macOS) + override var wantsExtendedDynamicRangeContent: Bool { + get { super.wantsExtendedDynamicRangeContent } + set { + if Thread.isMainThread { + super.wantsExtendedDynamicRangeContent = newValue + } else { + DispatchQueue.main.async { + super.wantsExtendedDynamicRangeContent = newValue + } + } + } + } + #endif +} + +/// Safely convert a C string to Swift String with UTF-8 validation. +/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. +/// mpv does not guarantee UTF-8 for log messages, error strings, or +/// system-encoded paths and Flutter codecs reject invalid UTF-8. +func safeString(_ cstr: UnsafePointer) -> String { + if let string = String(validatingUTF8: cstr) { + return string + } + + let length = strlen(cstr) + let buffer = UnsafeBufferPointer( + start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), + count: length + ) + return String(buffer.map { Character(Unicode.Scalar($0)) }) +} + +class MpvPlayerCoreBase: NSObject { + weak var delegate: MpvPlayerDelegate? + + var metalLayer: MpvMetalLayer? + var mpv: OpaquePointer? + var isInitialized = false + var isDisposing = false + var isPipActive = false + var hdrEnabled = true + var lastSigPeak = 0.0 + + let queue = DispatchQueue(label: "mpv", qos: .userInitiated) + private let queueKey = DispatchSpecificKey() + + private var pendingCommands: [UInt64: (Result) -> Void] = [:] + private let pendingCommandsLock = NSLock() + private var nextRequestId: UInt64 = 1 + + override init() { + super.init() + queue.setSpecific(key: queueKey, value: ()) + } + + func configurePlatformMpvOptions() {} + + func updateEDRMode(sigPeak: Double) {} + + func setupMpv() -> Bool { + guard let metalLayer else { return false } + + mpv = mpv_create() + guard let mpv else { + print("[MpvPlayerCore] Failed to create MPV context") + return false + } + + #if DEBUG + checkError(mpv_request_log_messages(mpv, "info")) + #else + checkError(mpv_request_log_messages(mpv, "warn")) + #endif + + var layer = metalLayer + checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) + applySharedMpvOptions() + configurePlatformMpvOptions() + + let initResult = mpv_initialize(mpv) + if initResult < 0 { + print("[MpvPlayerCore] mpv_initialize failed: \(safeString(mpv_error_string(initResult)))") + mpv_terminate_destroy(mpv) + self.mpv = nil + return false + } + + mpv_set_wakeup_callback( + mpv, + { context in + guard let context else { return } + let core = Unmanaged.fromOpaque(context).takeUnretainedValue() + core.readEvents() + }, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + + mpv_observe_property(mpv, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE) + return true + } + + func setLogLevel(_ level: String) { + guard let mpv else { return } + mpv_request_log_messages(mpv, level) + } + + func setProperty(_ name: String, value: String) { + guard mpv != nil else { return } + + if name == "hdr-enabled" { + let enabled = value == "yes" || value == "true" || value == "1" + setHDREnabled(enabled) + return + } + + mpv_set_property_string(mpv, name, value) + } + + func setHDREnabled(_ enabled: Bool) { + hdrEnabled = enabled + print("[MpvPlayerCore] HDR enabled: \(enabled)") + + if mpv != nil { + mpv_set_property_string(mpv, "target-colorspace-hint", enabled ? "yes" : "no") + } + + 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) + defer { mpv_free(cstr) } + return cstr.map { safeString($0) } + } + + func observeProperty(_ name: String, format: String) { + guard mpv != nil else { return } + + let mpvFormat: mpv_format + switch format { + case "double": + mpvFormat = MPV_FORMAT_DOUBLE + case "flag": + mpvFormat = MPV_FORMAT_FLAG + case "node": + mpvFormat = MPV_FORMAT_NODE + case "string": + mpvFormat = MPV_FORMAT_STRING + default: + return + } + + mpv_observe_property(mpv, 0, name, mpvFormat) + } + + func command(_ args: [String]) { + guard mpv != nil, !args.isEmpty else { return } + command(args[0], args: Array(args.dropFirst())) + } + + func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { + guard let mpv, !args.isEmpty else { + completion(.success(())) + return + } + + pendingCommandsLock.lock() + let requestId = nextRequestId + nextRequestId += 1 + pendingCommands[requestId] = completion + pendingCommandsLock.unlock() + + var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } + cargs.append(nil) + + cargs.withUnsafeBufferPointer { buffer in + var constPointers = buffer.map { UnsafePointer($0) } + let result = mpv_command_async(mpv, requestId, &constPointers) + if result < 0 { + pendingCommandsLock.lock() + let pending = pendingCommands.removeValue(forKey: requestId) + pendingCommandsLock.unlock() + + guard let pending else { return } + let error = NSError( + domain: "mpv", + code: Int(result), + userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(result))] + ) + DispatchQueue.main.async { + pending(.failure(error)) + } + } + } + + for pointer in cargs { + free(pointer) + } + } + + var isPaused: Bool { + guard let mpv else { return true } + var flag: Int32 = 0 + mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag) + return flag != 0 + } + + var duration: Double { + guard let mpv else { return 0 } + var value: Double = 0 + mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value) + return value + } + + var timePos: Double { + guard let mpv else { return 0 } + var value: Double = 0 + mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value) + return value + } + + func disposeSharedState(destroySynchronously: Bool) { + isDisposing = true + cancelPendingCommands() + + let mpvHandle = mpv + mpv = nil + + let destroy = { + if let mpvHandle { + mpv_set_wakeup_callback(mpvHandle, nil, nil) + mpv_terminate_destroy(mpvHandle) + } + } + + if destroySynchronously { + if DispatchQueue.getSpecific(key: queueKey) != nil { + destroy() + } else { + queue.sync(execute: destroy) + } + } else { + queue.async(execute: destroy) + } + } + + func applyGpuNextOptions() { + guard mpv != nil else { return } + mpv_set_property_string(mpv, "gpu-api", "vulkan") + mpv_set_property_string(mpv, "gpu-context", "moltenvk") + mpv_set_property_string(mpv, "vo", "gpu-next") + } + + private func applySharedMpvOptions() { + guard let mpv else { return } + checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) + 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")) + } + + private func cancelPendingCommands() { + pendingCommandsLock.lock() + let pending = pendingCommands + pendingCommands.removeAll() + pendingCommandsLock.unlock() + + let error = NSError( + domain: "mpv", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Player disposed"] + ) + for (_, completion) in pending { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + private func command(_ command: String, args: [String] = []) { + guard mpv != nil else { return } + + var cargs: [UnsafeMutablePointer?] = ([command] + args).map { strdup($0) } + cargs.append(nil) + defer { + for pointer in cargs { + free(pointer) + } + } + + cargs.withUnsafeBufferPointer { buffer in + var constPointers = buffer.map { UnsafePointer($0) } + _ = mpv_command(mpv, &constPointers) + } + } + + private func readEvents() { + queue.async { [weak self] in + guard let self, !self.isDisposing, let mpv = self.mpv else { return } + + while true { + let event = mpv_wait_event(mpv, 0) + guard let event else { break } + + if event.pointee.event_id == MPV_EVENT_NONE { + break + } + + self.handleEvent(event.pointee) + } + } + } + + private func handleEvent(_ event: mpv_event) { + switch event.event_id { + case MPV_EVENT_PROPERTY_CHANGE: + guard let data = event.data else { break } + let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee + let name = safeString(property.name) + handlePropertyChange(name: name, property: property) + + case MPV_EVENT_COMMAND_REPLY: + let requestId = event.reply_userdata + pendingCommandsLock.lock() + let completion = pendingCommands.removeValue(forKey: requestId) + pendingCommandsLock.unlock() + + guard let completion else { break } + if event.error < 0 { + let error = NSError( + domain: "mpv", + code: Int(event.error), + userInfo: [NSLocalizedDescriptionKey: safeString(mpv_error_string(event.error))] + ) + DispatchQueue.main.async { + completion(.failure(error)) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + + case MPV_EVENT_FILE_LOADED: + DispatchQueue.main.async { + self.delegate?.onEvent(name: "file-loaded", data: nil) + } + + case MPV_EVENT_END_FILE: + DispatchQueue.main.async { + self.delegate?.onEvent(name: "end-file", data: nil) + } + + case MPV_EVENT_SHUTDOWN: + print("[MpvPlayerCore] MPV shutdown event") + + case MPV_EVENT_PLAYBACK_RESTART: + DispatchQueue.main.async { + self.delegate?.onEvent(name: "playback-restart", data: nil) + } + + case MPV_EVENT_LOG_MESSAGE: + if let messagePointer = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let message = messagePointer.pointee + let prefix = message.prefix.map { safeString($0) } ?? "" + let level = message.level.map { safeString($0) } ?? "" + let text = message.text.map { safeString($0) } ?? "" + + DispatchQueue.main.async { + self.delegate?.onEvent( + name: "log-message", + data: ["prefix": prefix, "level": level, "text": text] + ) + } + } + + default: + break + } + } + + private func handlePropertyChange(name: String, property: mpv_event_property) { + var value: Any? + + switch property.format { + case MPV_FORMAT_DOUBLE: + if let data = property.data { + value = data.assumingMemoryBound(to: Double.self).pointee + } + + case MPV_FORMAT_FLAG: + if let data = property.data { + value = data.assumingMemoryBound(to: Int32.self).pointee != 0 + } + + case MPV_FORMAT_NODE: + if let data = property.data { + let node = data.assumingMemoryBound(to: mpv_node.self).pointee + value = convertNode(node) + } + + case MPV_FORMAT_STRING: + if let data = property.data { + let cstring = data.assumingMemoryBound(to: UnsafePointer?.self).pointee + value = cstring.map { safeString($0) } + } + + default: + break + } + + 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) + } + } + + private func convertNode(_ node: mpv_node) -> Any? { + switch node.format { + case MPV_FORMAT_STRING: + return node.u.string.map { safeString($0) } + + case MPV_FORMAT_FLAG: + return node.u.flag != 0 + + case MPV_FORMAT_INT64: + return node.u.int64 + + case MPV_FORMAT_DOUBLE: + return node.u.double_ + + case MPV_FORMAT_NODE_ARRAY: + guard let list = node.u.list?.pointee else { return nil } + var array = [Any]() + for index in 0.. 1 && Int(newValue.height) > 1 { - super.drawableSize = newValue - } - } - } - - // Fix for target-colorspace-hint - needs main thread for EDR - @available(iOS 16.0, *) - override var wantsExtendedDynamicRangeContent: Bool { - get { return super.wantsExtendedDynamicRangeContent } - set { - if Thread.isMainThread { - super.wantsExtendedDynamicRangeContent = newValue - } else { - DispatchQueue.main.sync { - super.wantsExtendedDynamicRangeContent = newValue - } - } - } - } -} - -/// Safely convert a C string to Swift String with UTF-8 validation. -/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. -/// mpv does not guarantee UTF-8 for log messages, error strings, or -/// system-encoded paths — sending invalid UTF-8 through Flutter's -/// StandardMessageCodec causes FormatException crashes. -private func safeString(_ cstr: UnsafePointer) -> String { - if let s = String(validatingUTF8: cstr) { - return s - } - // Latin-1 fallback: interpret each byte as its Unicode scalar - let len = strlen(cstr) - let buf = UnsafeBufferPointer(start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: len) - return String(buf.map { Character(Unicode.Scalar($0)) }) -} - -/// Core MPV player using Metal rendering for iOS -class MpvPlayerCore: NSObject { - - // MARK: - Properties - - private var metalLayer: MetalLayer? - private var containerView: UIView? // Container for proper EDR activation - private var mpv: OpaquePointer? + private var containerView: UIView? private weak var window: UIWindow? - private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated) - weak var delegate: MpvPlayerDelegate? - - private(set) var isInitialized = false - private var isDisposing = false // Flag to prevent race conditions during disposal - - // PiP state - var isPipActive = false - var isPipStarting = false // VO switched for PiP, waiting for first frame - - // HDR settings - private var hdrEnabled = true // User preference for HDR - private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation - - // Async command tracking to prevent UI blocking - private var pendingCommands: [UInt64: (Result) -> Void] = [:] - private var pendingCommandsLock = NSLock() - private var nextRequestId: UInt64 = 1 - - // MARK: - Initialization + var isPipStarting = false func initialize(in window: UIWindow) -> Bool { guard !isInitialized else { @@ -89,14 +17,11 @@ 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() + let layer = MpvMetalLayer() layer.frame = container.bounds layer.contentsScale = UIScreen.main.nativeScale layer.framebufferOnly = true @@ -106,10 +31,8 @@ class MpvPlayerCore: NSObject { containerView = container metalLayer = layer - // Add container view to window (behind Flutter's root view controller) window.insertSubview(container, at: 0) - // Initialize MPV with this Metal layer guard setupMpv() else { print("[MpvPlayerCore] Failed to setup MPV") layer.removeFromSuperlayer() @@ -119,7 +42,6 @@ class MpvPlayerCore: NSObject { return false } - // Setup background/foreground notifications setupNotifications() isInitialized = true @@ -127,60 +49,106 @@ class MpvPlayerCore: NSObject { return true } - private func setupMpv() -> Bool { - guard let metalLayer = metalLayer else { return false } + func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool { + guard let mpv else { return false } - mpv = mpv_create() - guard mpv != nil else { - print("[MpvPlayerCore] Failed to create MPV context") - return false - } + print("[MpvPlayerCore] Switching to pip VO for PiP") - // Logging - #if DEBUG - checkError(mpv_request_log_messages(mpv, "info")) - #else - checkError(mpv_request_log_messages(mpv, "warn")) - #endif + metalLayer?.removeFromSuperlayer() - // Set the Metal layer as the render target (must use local var for &) - var layer = metalLayer - checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) + mpv_set_property_string(mpv, "vid", "no") - // Video output settings for Metal/Vulkan - checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) - 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")) + var pointer = Int64(Int(bitPattern: layerPtr)) + mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer) - // Initialize MPV - let initResult = mpv_initialize(mpv) - if initResult < 0 { - 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 - guard let ctx = ctx else { return } // Safe guard instead of force unwrap - 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) + mpv_set_property_string(mpv, "vo", "pip") + mpv_set_property_string(mpv, "vid", "auto") + print("[MpvPlayerCore] Switched to pip VO successfully") return true } - // MARK: - Background/Foreground Handling + func switchToGpuNextVO() -> Bool { + guard let mpv, let metalLayer else { return false } + + print("[MpvPlayerCore] Switching back to gpu-next VO") + + mpv_set_property_string(mpv, "vid", "no") + + var layer = metalLayer + mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer) + + applyGpuNextOptions() + mpv_set_property_string(mpv, "vid", "auto") + + if metalLayer.superlayer == nil, let containerView { + containerView.layer.addSublayer(metalLayer) + } + + print("[MpvPlayerCore] Switched back to gpu-next VO successfully") + return true + } + + func setVisible(_ visible: Bool) { + guard let containerView else { return } + + if visible { + containerView.removeFromSuperview() + window?.insertSubview(containerView, at: 0) + } + + containerView.isHidden = !visible + } + + func updateFrame(_ frame: CGRect? = nil) { + guard let metalLayer, let containerView else { return } + + if let frame { + containerView.frame = frame + metalLayer.frame = containerView.bounds + } else if let window { + containerView.frame = window.bounds + metalLayer.frame = containerView.bounds + } + + let scale = UIScreen.main.nativeScale + metalLayer.drawableSize = CGSize( + width: metalLayer.frame.width * scale, + height: metalLayer.frame.height * scale + ) + } + + override func updateEDRMode(sigPeak: Double) { + guard let metalLayer else { return } + + var edrHeadroom: CGFloat = 1.0 + if #available(iOS 16.0, *) { + edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0 + metalLayer.wantsExtendedDynamicRangeContent = + hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + } + + let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + print( + "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" + ) + } + + func dispose() { + NotificationCenter.default.removeObserver(self) + disposeSharedState(destroySynchronously: false) + + metalLayer?.removeFromSuperlayer() + metalLayer = nil + containerView?.removeFromSuperview() + containerView = nil + isInitialized = false + print("[MpvPlayerCore] Disposed") + } + + deinit { + dispose() + } private func setupNotifications() { NotificationCenter.default.addObserver( @@ -202,7 +170,7 @@ class MpvPlayerCore: NSObject { print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video") return } - // Disable video output to fix black screen when returning from background + print("[MpvPlayerCore] Entering background - disabling video") if mpv != nil { mpv_set_option_string(mpv, "vid", "no") @@ -210,506 +178,14 @@ class MpvPlayerCore: NSObject { } @objc private func enterForeground() { - // Skip if PiP is active - video is already enabled if isPipActive { print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore") return } - // Re-enable video output + print("[MpvPlayerCore] Entering foreground - enabling video") if mpv != nil { mpv_set_option_string(mpv, "vid", "auto") } } - - // MARK: - MPV Properties and Commands - - 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) - defer { mpv_free(cstr) } - return cstr.map { String(cString: $0) } - } - - func setLogLevel(_ level: String) { - guard let mpv = mpv else { return } - mpv_request_log_messages(mpv, level) - } - - func observeProperty(_ name: String, format: String) { - guard mpv != nil else { return } - - let mpvFormat: mpv_format - switch format { - case "double": mpvFormat = MPV_FORMAT_DOUBLE - case "flag": mpvFormat = MPV_FORMAT_FLAG - case "node": mpvFormat = MPV_FORMAT_NODE - case "string": mpvFormat = MPV_FORMAT_STRING - default: return - } - - mpv_observe_property(mpv, 0, name, mpvFormat) - } - - func command(_ args: [String]) { - guard mpv != nil, !args.isEmpty else { return } - command(args[0], args: Array(args.dropFirst())) - } - - /// Execute an MPV command asynchronously to prevent UI blocking. - /// Uses mpv_command_async which returns immediately; the completion is called - /// when MPV_EVENT_COMMAND_REPLY is received. - func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { - guard let mpv = mpv, !args.isEmpty else { - completion(.success(())) - return - } - - // Generate unique request ID - pendingCommandsLock.lock() - let requestId = nextRequestId - nextRequestId += 1 - pendingCommands[requestId] = completion - pendingCommandsLock.unlock() - - // Build array of C strings for mpv_command_async - var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } - cargs.append(nil) // null-terminate - - // mpv_command_async returns immediately - cargs.withUnsafeBufferPointer { buffer in - var constPtrs = buffer.map { UnsafePointer($0) } - let result = mpv_command_async(mpv, requestId, &constPtrs) - if result < 0 { - // Command submission failed, complete immediately with error - pendingCommandsLock.lock() - if let pending = pendingCommands.removeValue(forKey: requestId) { - pendingCommandsLock.unlock() - let error = NSError(domain: "mpv", code: Int(result), - userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(result))]) - DispatchQueue.main.async { pending(.failure(error)) } - } else { - pendingCommandsLock.unlock() - } - } - } - - // Free the C strings - for ptr in cargs { - free(ptr) - } - } - - // MARK: - PiP VO Switching - - /// Switch from gpu-next to vo_pip for PiP rendering. - /// vo_pip feeds VideoToolbox CVPixelBuffers to an AVSampleBufferDisplayLayer - /// which is required by AVPictureInPictureController. - /// - /// Strategy: disable the video track (`vid=no`) to tear down the active VO, - /// then set `wid` and `vo` while no VO is running, then re-enable video. - /// This avoids the crash where gpu-next calls `drawableSize` on an - /// AVSampleBufferDisplayLayer, and ensures the new VO reads `wid` at init. - func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool { - guard let mpv = mpv else { return false } - - print("[MpvPlayerCore] Switching to pip VO for PiP") - - // Detach the Metal layer from the view hierarchy on the main thread BEFORE - // vid=no, so the 'vo' thread VO uninit has no UIKit work to do (avoids - // "layout engine from background thread" crash). - metalLayer?.removeFromSuperlayer() - - // 1. Disable video track — tears down the current VO completely - mpv_set_property_string(mpv, "vid", "no") - - // 2. Point wid at the PiP sample-buffer layer (safe — no active VO) - var ptr = Int64(Int(bitPattern: layerPtr)) - mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &ptr) - - // 3. Set VO to pip (stored, not yet created) - mpv_set_property_string(mpv, "vo", "pip") - - // 4. Re-enable video — creates pip VO with our layer as wid - mpv_set_property_string(mpv, "vid", "auto") - - print("[MpvPlayerCore] Switched to pip VO successfully") - return true - } - - /// Switch back from vo_pip to gpu-next VO for normal rendering. - /// Same strategy: vid=no → reconfigure → vid=auto. - func switchToGpuNextVO() -> Bool { - guard let mpv = mpv, let metalLayer = metalLayer else { return false } - - print("[MpvPlayerCore] Switching back to gpu-next VO") - - // 1. Disable video track — tears down pip VO - mpv_set_property_string(mpv, "vid", "no") - - // 2. Point wid back at the Metal layer (safe — no active VO) - var layer = metalLayer - mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer) - - // 3. Restore gpu-next + Vulkan/MoltenVK settings (read at VO creation) - mpv_set_property_string(mpv, "gpu-api", "vulkan") - mpv_set_property_string(mpv, "gpu-context", "moltenvk") - mpv_set_property_string(mpv, "vo", "gpu-next") - - // 4. Re-enable video — creates gpu-next VO with Metal layer as wid - mpv_set_property_string(mpv, "vid", "auto") - - // Re-attach the Metal layer to the view hierarchy (was detached in switchToPipVO) - if metalLayer.superlayer == nil, let container = containerView { - container.layer.addSublayer(metalLayer) - } - - print("[MpvPlayerCore] Switched back to gpu-next VO successfully") - return true - } - - /// Whether mpv is currently paused - var isPaused: Bool { - guard let mpv = mpv else { return true } - var flag: Int32 = 0 - mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag) - return flag != 0 - } - - /// Current playback duration in seconds - var duration: Double { - guard let mpv = mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value) - return value - } - - /// Current playback time in seconds - var timePos: Double { - guard let mpv = mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value) - return value - } - - // MARK: - Visibility - - func setVisible(_ visible: Bool) { - guard let container = containerView else { return } - - if visible { - // Re-insert at the bottom of the window view stack - container.removeFromSuperview() - window?.insertSubview(container, at: 0) - } - - container.isHidden = !visible - } - - func updateFrame(_ frame: CGRect? = nil) { - guard let metalLayer = metalLayer, let container = containerView else { return } - - if let frame = frame { - container.frame = frame - metalLayer.frame = container.bounds - } else if let window = window { - container.frame = window.bounds - metalLayer.frame = container.bounds - } - - // Update drawable size for proper scaling - let scale = UIScreen.main.nativeScale - metalLayer.drawableSize = CGSize( - width: metalLayer.frame.width * scale, - height: metalLayer.frame.height * scale - ) - } - - // MARK: - Private Helpers - - private func command(_ cmd: String, args: [String] = []) { - guard mpv != nil else { return } - - // Build array of C strings for mpv_command - var cargs: [UnsafeMutablePointer?] = ([cmd] + args).map { strdup($0) } - cargs.append(nil) // null-terminate - defer { - for ptr in cargs { - free(ptr) - } - } - - // mpv_command expects UnsafePointer, use withUnsafeBufferPointer for the conversion - cargs.withUnsafeBufferPointer { buffer in - var constPtrs = buffer.map { UnsafePointer($0) } - _ = mpv_command(mpv, &constPtrs) - } - } - - private func readEvents() { - queue.async { [weak self] in - guard let self = self, !self.isDisposing, let mpv = self.mpv else { return } - - while true { - let event = mpv_wait_event(mpv, 0) - guard let eventPtr = event else { break } - - if eventPtr.pointee.event_id == MPV_EVENT_NONE { - break - } - - self.handleEvent(eventPtr.pointee) - } - } - } - - private func handleEvent(_ event: mpv_event) { - switch event.event_id { - case MPV_EVENT_PROPERTY_CHANGE: - guard let data = event.data else { break } - let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee - let name = String(cString: property.name) - handlePropertyChange(name: name, property: property) - - case MPV_EVENT_COMMAND_REPLY: - // Handle async command completion - let requestId = event.reply_userdata - pendingCommandsLock.lock() - let completion = pendingCommands.removeValue(forKey: requestId) - pendingCommandsLock.unlock() - - if let completion = completion { - if event.error < 0 { - let error = NSError(domain: "mpv", code: Int(event.error), - userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(event.error))]) - DispatchQueue.main.async { completion(.failure(error)) } - } else { - DispatchQueue.main.async { completion(.success(())) } - } - } - - case MPV_EVENT_FILE_LOADED: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "file-loaded", data: nil) - } - - case MPV_EVENT_END_FILE: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: nil) - } - - case MPV_EVENT_SHUTDOWN: - print("[MpvPlayerCore] MPV shutdown event") - - case MPV_EVENT_PLAYBACK_RESTART: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "playback-restart", data: nil) - } - - case MPV_EVENT_LOG_MESSAGE: - if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { - let msg = msgPtr.pointee - let prefix = msg.prefix.map { safeString($0) } ?? "" - let level = msg.level.map { safeString($0) } ?? "" - let text = msg.text.map { safeString($0) } ?? "" - - DispatchQueue.main.async { - self.delegate?.onEvent(name: "log-message", data: [ - "prefix": prefix, - "level": level, - "text": text - ]) - } - } - - default: - break - } - } - - private func handlePropertyChange(name: String, property: mpv_event_property) { - var value: Any? - - switch property.format { - case MPV_FORMAT_DOUBLE: - if let ptr = property.data { - value = ptr.assumingMemoryBound(to: Double.self).pointee - } - - case MPV_FORMAT_FLAG: - if let ptr = property.data { - value = ptr.assumingMemoryBound(to: Int32.self).pointee != 0 - } - - case MPV_FORMAT_NODE: - if let ptr = property.data { - let node = ptr.assumingMemoryBound(to: mpv_node.self).pointee - value = convertNode(node) - } - - case MPV_FORMAT_STRING: - if let ptr = property.data { - let cstr = ptr.assumingMemoryBound(to: UnsafePointer?.self).pointee - value = cstr.map { safeString($0) } - } - - default: - 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: - return node.u.string.map { safeString($0) } - - case MPV_FORMAT_FLAG: - return node.u.flag != 0 - - case MPV_FORMAT_INT64: - return node.u.int64 - - case MPV_FORMAT_DOUBLE: - return node.u.double_ - - case MPV_FORMAT_NODE_ARRAY: - guard let list = node.u.list?.pointee else { return nil } - var array = [Any]() - for i in 0.. on State { + void setStateIfMounted(VoidCallback fn) { + if (mounted) { + setState(fn); + } + } +} diff --git a/lib/mixins/server_bound_media_mixin.dart b/lib/mixins/server_bound_media_mixin.dart new file mode 100644 index 00000000..cb9f20c8 --- /dev/null +++ b/lib/mixins/server_bound_media_mixin.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; + +import '../models/plex_metadata.dart'; +import '../services/plex_client.dart'; +import '../utils/global_key_utils.dart'; +import '../utils/provider_extensions.dart'; + +/// Shared helpers for screens bound to a single [PlexMetadata] item/server. +mixin ServerBoundMediaMixin on State { + PlexMetadata get serverBoundMetadata; + + bool get isServerBoundOffline => false; + + String? get serverBoundServerId => serverBoundMetadata.serverId; + + String toServerBoundGlobalKey(String ratingKey, {String? serverId}) => + buildGlobalKey(serverId ?? serverBoundServerId ?? '', ratingKey); + + PlexClient? getServerBoundClient(BuildContext context) => + context.getClientForMetadataOrNull(serverBoundMetadata, isOffline: isServerBoundOffline); +} diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index e7846fbb..a3bacbaf 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -51,6 +51,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { bool initialized = false; /// Whether the player has been disposed. + @override bool get disposed => _disposed; /// The method channel for platform communication. diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 4ec8fada..7d3fcd8c 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; -import '../utils/global_key_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; @@ -36,7 +35,6 @@ import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import '../utils/scroll_utils.dart'; -import '../utils/provider_extensions.dart'; import '../utils/dialogs.dart'; import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; @@ -48,6 +46,8 @@ import '../widgets/overlay_sheet.dart'; import '../widgets/placeholder_container.dart'; import '../mixins/watch_state_aware.dart'; import '../mixins/deletion_aware.dart'; +import '../mixins/mounted_set_state_mixin.dart'; +import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; import 'season_detail_screen.dart'; @@ -62,7 +62,8 @@ class MediaDetailScreen extends StatefulWidget { State createState() => _MediaDetailScreenState(); } -class _MediaDetailScreenState extends State with WatchStateAware, DeletionAware { +class _MediaDetailScreenState extends State + with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin { List _seasons = []; bool _isLoadingSeasons = false; Completer? _seasonsCompleter; @@ -110,13 +111,11 @@ class _MediaDetailScreenState extends State with WatchStateAw final _castSectionKey = GlobalKey(); final _seasonsSectionKey = GlobalKey(); - String _toGlobalKey(String ratingKey, {String? serverId}) => - buildGlobalKey(serverId ?? widget.metadata.serverId ?? '', ratingKey); + @override + PlexMetadata get serverBoundMetadata => widget.metadata; - /// Calls [setState] only if the widget is still mounted. - void _setStateIfMounted(VoidCallback fn) { - if (mounted) setState(fn); - } + @override + bool get isServerBoundOffline => widget.isOffline; // WatchStateAware: watch the show/movie and all season ratingKeys @override @@ -129,16 +128,16 @@ class _MediaDetailScreenState extends State with WatchStateAw } @override - String? get watchStateServerId => widget.metadata.serverId; + String? get watchStateServerId => serverBoundServerId; @override Set? get watchedGlobalKeys { - final serverId = widget.metadata.serverId; + final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; for (final season in _seasons) { - keys.add(_toGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); } return keys; } @@ -161,16 +160,16 @@ class _MediaDetailScreenState extends State with WatchStateAw } @override - String? get deletionServerId => widget.metadata.serverId; + String? get deletionServerId => serverBoundServerId; @override Set? get deletionGlobalKeys { - final serverId = widget.metadata.serverId; + final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = {_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; + final keys = {toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; for (final season in _seasons) { - keys.add(_toGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); + keys.add(toServerBoundGlobalKey(season.ratingKey, serverId: season.serverId ?? serverId)); } return keys; } @@ -238,7 +237,7 @@ class _MediaDetailScreenState extends State with WatchStateAw final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; if (metadata != null) { - _setStateIfMounted(() { + setStateIfMounted(() { _fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); _onDeckEpisode = onDeckEpisode?.copyWith( serverId: widget.metadata.serverId, @@ -250,7 +249,7 @@ class _MediaDetailScreenState extends State with WatchStateAw // Refresh seasons for updated watched counts (also without loader) if (widget.metadata.isShow) { final seasons = await client.getChildren(widget.metadata.ratingKey); - _setStateIfMounted(() { + setStateIfMounted(() { _seasons = seasons .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); @@ -655,8 +654,9 @@ class _MediaDetailScreenState extends State with WatchStateAw try { final count = await downloadProvider.queueDownload(metadata, client); if (context.mounted) { - final message = - count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; + final message = count > 1 + ? t.downloads.episodesQueued(count: count) + : t.downloads.downloadQueued; showSuccessSnackBar(context, message); } } on CellularDownloadBlockedException { @@ -862,16 +862,12 @@ class _MediaDetailScreenState extends State with WatchStateAw AppIcon( Symbols.star_rounded, fill: hasRating ? 1 : 0, - color: hasRating - ? Colors.amber - : Theme.of(context).colorScheme.onSecondaryContainer, + color: hasRating ? Colors.amber : Theme.of(context).colorScheme.onSecondaryContainer, size: 16, ), const SizedBox(width: 4), Text( - hasRating - ? formatRating(starValue) - : t.mediaMenu.rate, + hasRating ? formatRating(starValue) : t.mediaMenu.rate, style: TextStyle( color: Theme.of(context).colorScheme.onSecondaryContainer, fontSize: 13, @@ -939,10 +935,7 @@ class _MediaDetailScreenState extends State with WatchStateAw /// Get the correct PlexClient for this metadata's server /// Returns null in offline mode or if serverId is null PlexClient? _getClientForMetadata(BuildContext context) { - if (widget.isOffline || widget.metadata.serverId == null) { - return null; - } - return context.getClientForServer(widget.metadata.serverId!); + return getServerBoundClient(context); } Future _loadFullMetadata() async { @@ -1057,12 +1050,12 @@ class _MediaDetailScreenState extends State with WatchStateAw final seasonsWithServerId = seasons .map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); - _setStateIfMounted(() { + setStateIfMounted(() { _seasons = seasonsWithServerId; _isLoadingSeasons = false; }); } catch (e) { - _setStateIfMounted(() { + setStateIfMounted(() { _isLoadingSeasons = false; }); } finally { @@ -1139,7 +1132,7 @@ class _MediaDetailScreenState extends State with WatchStateAw .map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); - _setStateIfMounted(() { + setStateIfMounted(() { _extras = extrasWithServerId; }); } catch (e) { @@ -1423,7 +1416,6 @@ class _MediaDetailScreenState extends State with WatchStateAw ); } - /// Handle key events for the extras row (locked focus pattern) KeyEventResult _handleExtrasKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -1611,7 +1603,7 @@ class _MediaDetailScreenState extends State with WatchStateAw final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey); if (nextEpisode != null) { - _setStateIfMounted(() { + setStateIfMounted(() { _onDeckEpisode = nextEpisode; }); appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}'); @@ -1651,7 +1643,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } // Single setState to minimize rebuilds - scroll position is preserved by controller - _setStateIfMounted(() { + setStateIfMounted(() { _fullMetadata = metadataWithServerId; if (updatedSeasons != null) { _seasons = updatedSeasons; @@ -1690,10 +1682,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } // Skip Season 0 (Specials) — prefer the first regular season - final firstSeason = _seasons.firstWhere( - (s) => (s.index ?? 0) > 0, - orElse: () => _seasons.first, - ); + final firstSeason = _seasons.firstWhere((s) => (s.index ?? 0) > 0, orElse: () => _seasons.first); // Get episodes of the first season List episodes; @@ -1864,374 +1853,379 @@ class _MediaDetailScreenState extends State with WatchStateAw final content = OverlaySheetHost( child: Focus( - onKeyEvent: handleBack, - child: Scaffold( - body: Stack( - children: [ - CustomScrollView( - controller: _scrollController, - slivers: [ - // Hero header with background art - SliverToBoxAdapter( - child: Stack( - children: [ - // Background Art (fixed height, no parallax) - SizedBox( - height: headerHeight, - width: double.infinity, - child: (metadata.art != null || metadata.backgroundSquare != null) - ? Builder( - builder: (context) { - final containerAspect = size.width / headerHeight; - final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); + onKeyEvent: handleBack, + child: Scaffold( + body: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + slivers: [ + // Hero header with background art + SliverToBoxAdapter( + child: Stack( + children: [ + // Background Art (fixed height, no parallax) + SizedBox( + height: headerHeight, + width: double.infinity, + child: (metadata.art != null || metadata.backgroundSquare != null) + ? Builder( + builder: (context) { + final containerAspect = size.width / headerHeight; + final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); - // Check for offline local file first - if (widget.isOffline && widget.metadata.serverId != null) { - final localPath = context.read().getArtworkLocalPath( - widget.metadata.serverId!, - heroArtPath, - ); - if (localPath != null && File(localPath).existsSync()) { - return Image.file( - File(localPath), - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(), + // Check for offline local file first + if (widget.isOffline && widget.metadata.serverId != null) { + final localPath = context.read().getArtworkLocalPath( + widget.metadata.serverId!, + heroArtPath, ); + if (localPath != null && File(localPath).existsSync()) { + return Image.file( + File(localPath), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(), + ); + } + // Offline but no local file - show placeholder + return const PlaceholderContainer(); } - // Offline but no local file - show placeholder - return const PlaceholderContainer(); - } - // Online - use network image - final client = _getClientForMetadata(context); - final mediaQuery = MediaQuery.of(context); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final imageUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: heroArtPath, - maxWidth: mediaQuery.size.width, - maxHeight: mediaQuery.size.height * 0.6, - devicePixelRatio: dpr, - imageType: ImageType.art, - ); + // Online - use network image + final client = _getClientForMetadata(context); + final mediaQuery = MediaQuery.of(context); + final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: heroArtPath, + maxWidth: mediaQuery.size.width, + maxHeight: mediaQuery.size.height * 0.6, + devicePixelRatio: dpr, + imageType: ImageType.art, + ); - return blurArtwork(CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: (context, url) => const PlaceholderContainer(), - errorWidget: (context, url, error) => const PlaceholderContainer(), - )); - }, - ) - : const PlaceholderContainer(), - ), - - // Gradient overlay - Positioned( - top: 0, - left: 0, - right: 0, - bottom: -1, // Extend 1px past to prevent subpixel gap - child: Builder( - builder: (context) { - final bgColor = Theme.of(context).scaffoldBackgroundColor; - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: const [0.3, 0.8, 1.0], - ), - ), - ); - }, + return blurArtwork( + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => const PlaceholderContainer(), + errorWidget: (context, url, error) => const PlaceholderContainer(), + ), + ); + }, + ) + : const PlaceholderContainer(), ), - ), - // Content at bottom - Positioned( - bottom: 16, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Clear logo or title - if (metadata.clearLogo != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - // Check for offline local file first - if (widget.isOffline && widget.metadata.serverId != null) { - final localPath = context.read().getArtworkLocalPath( - widget.metadata.serverId!, - metadata.clearLogo, + // Gradient overlay + Positioned( + top: 0, + left: 0, + right: 0, + bottom: -1, // Extend 1px past to prevent subpixel gap + child: Builder( + builder: (context) { + final bgColor = Theme.of(context).scaffoldBackgroundColor; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], + stops: const [0.3, 0.8, 1.0], + ), + ), + ); + }, + ), + ), + + // Content at bottom + Positioned( + bottom: 16, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Clear logo or title + if (metadata.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: Builder( + builder: (context) { + // Check for offline local file first + if (widget.isOffline && widget.metadata.serverId != null) { + final localPath = context.read().getArtworkLocalPath( + widget.metadata.serverId!, + metadata.clearLogo, + ); + if (localPath != null && File(localPath).existsSync()) { + return Image.file( + File(localPath), + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + errorBuilder: (context, error, stackTrace) => + _buildTitleText(context, metadata.title), + ); + } + // Offline but no local file - show title text + return _buildTitleText(context, metadata.title); + } + + // Online - use network image + final client = _getClientForMetadata(context); + final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.clearLogo, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, ); - if (localPath != null && File(localPath).existsSync()) { - return Image.file( - File(localPath), + + return blurArtwork( + CachedNetworkImage( + imageUrl: logoUrl, + filterQuality: FilterQuality.medium, fit: BoxFit.contain, alignment: Alignment.centerLeft, - errorBuilder: (context, error, stackTrace) => - _buildTitleText(context, metadata.title), - ); - } - // Offline but no local file - show title text - return _buildTitleText(context, metadata.title); - } - - // Online - use network image - final client = _getClientForMetadata(context); - final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.clearLogo, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); - - return blurArtwork(CachedNetworkImage( - imageUrl: logoUrl, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - memCacheWidth: (400 * dpr).clamp(200, 800).round(), - placeholder: (context, url) => Align( - alignment: Alignment.centerLeft, - child: Text( - metadata.title, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.3), - fontWeight: FontWeight.bold, - shadows: [ - Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8), - ], + memCacheWidth: (400 * dpr).clamp(200, 800).round(), + placeholder: (context, url) => Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Colors.white.withValues(alpha: 0.3), + fontWeight: FontWeight.bold, + shadows: [ + Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + errorWidget: (context, url, error) { + return _buildTitleText(context, metadata.title); + }, ), - ), - errorWidget: (context, url, error) { - return _buildTitleText(context, metadata.title); - }, - ), sigma: 10, clip: false); - }, + sigma: 10, + clip: false, + ); + }, + ), + ) + else + Text( + metadata.title, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ) - else - Text( - metadata.title, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), + const SizedBox(height: 12), - // Metadata chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (metadata.year != null) _buildMetadataChip('${metadata.year}'), - if (metadata.contentRating != null) - _buildMetadataChip(formatContentRating(metadata.contentRating!)), - if (metadata.duration != null) - _buildMetadataChip(formatDurationTextual(metadata.duration!)), - ..._buildRatingChips(metadata), - ], - ), - const SizedBox(height: 16), - // Action buttons - _buildActionButtons(metadata), - ], + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) _buildMetadataChip('${metadata.year}'), + if (metadata.contentRating != null) + _buildMetadataChip(formatContentRating(metadata.contentRating!)), + if (metadata.duration != null) + _buildMetadataChip(formatDurationTextual(metadata.duration!)), + ..._buildRatingChips(metadata), + ], + ), + const SizedBox(height: 16), + // Action buttons + _buildActionButtons(metadata), + ], + ), ), ), ), - ), - ], - ), - ), - - // Main content - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Summary - if (metadata.summary != null) ...[ - Text( - key: _overviewSectionKey, - t.discover.overview, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - Focus( - focusNode: _overviewFocusNode, - onKeyEvent: _handleOverviewKeyEvent, - onFocusChange: (_) => setState(() {}), - child: Builder( - builder: (context) { - final showFocus = - _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all( - color: showFocus - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5) - : Colors.transparent, - width: 2, - ), - ), - child: () { - final summaryStyle = - Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6); - if (isTv) { - return Text(metadata.summary!, style: summaryStyle); - } - return CollapsibleText( - text: metadata.summary!, - maxLines: isMobile ? 6 : 4, - style: summaryStyle, - ); - }(), - ); - }, - ), - ), - const SizedBox(height: 24), - ], - - // Seasons (for TV shows) - if (isShow) ...[ - Text( - key: _seasonsSectionKey, - t.discover.seasons, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - if (_isLoadingSeasons) - const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), - ) - else if (_seasons.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noSeasonsFound, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ) - else if (size.width >= 600) - _buildHorizontalSeasons() - else - _buildVerticalSeasons(), - const SizedBox(height: 24), - ], - - // Cast - if (metadata.role != null && metadata.role!.isNotEmpty) ...[ - Text( - key: _castSectionKey, - t.discover.cast, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - _buildCastSection(metadata), - const SizedBox(height: 24), - ], - - // Trailers & Extras Section - if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ - Text( - key: _extrasSectionKey, - t.discover.extras, - style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - _buildExtrasSection(), - const SizedBox(height: 24), - ], - - // Additional info - if (metadata.studio != null) ...[ - _buildInfoRow(t.discover.studio, metadata.studio!), - const SizedBox(height: 12), - ], - if (metadata.contentRating != null) ...[ - _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), - const SizedBox(height: 12), - ], ], ), ), - ), - SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)), - ], - ), - // Sticky top bar with fading background - Positioned( - top: 0, - left: 0, - right: 0, - child: IgnorePointer( - ignoring: _scrollOffset < 50, - child: AnimatedOpacity( - opacity: (_scrollOffset / 100).clamp(0.0, 1.0), - duration: const Duration(milliseconds: 150), - child: Container( - height: MediaQuery.of(context).padding.top + 58, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8), - Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.5), - Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0), + + // Main content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary + if (metadata.summary != null) ...[ + Text( + key: _overviewSectionKey, + t.discover.overview, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Focus( + focusNode: _overviewFocusNode, + onKeyEvent: _handleOverviewKeyEvent, + onFocusChange: (_) => setState(() {}), + child: Builder( + builder: (context) { + final showFocus = + _overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all( + color: showFocus + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5) + : Colors.transparent, + width: 2, + ), + ), + child: () { + final summaryStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6); + if (isTv) { + return Text(metadata.summary!, style: summaryStyle); + } + return CollapsibleText( + text: metadata.summary!, + maxLines: isMobile ? 6 : 4, + style: summaryStyle, + ); + }(), + ); + }, + ), + ), + const SizedBox(height: 24), + ], + + // Seasons (for TV shows) + if (isShow) ...[ + Text( + key: _seasonsSectionKey, + t.discover.seasons, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + if (_isLoadingSeasons) + const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()), + ) + else if (_seasons.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + t.messages.noSeasonsFound, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey), + ), + ), + ) + else if (size.width >= 600) + _buildHorizontalSeasons() + else + _buildVerticalSeasons(), + const SizedBox(height: 24), + ], + + // Cast + if (metadata.role != null && metadata.role!.isNotEmpty) ...[ + Text( + key: _castSectionKey, + t.discover.cast, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _buildCastSection(metadata), + const SizedBox(height: 24), + ], + + // Trailers & Extras Section + if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ + Text( + key: _extrasSectionKey, + t.discover.extras, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + _buildExtrasSection(), + const SizedBox(height: 24), + ], + + // Additional info + if (metadata.studio != null) ...[ + _buildInfoRow(t.discover.studio, metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)), + const SizedBox(height: 12), + ], ], - stops: const [0.0, 0.3, 1.0], + ), + ), + ), + SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)), + ], + ), + // Sticky top bar with fading background + Positioned( + top: 0, + left: 0, + right: 0, + child: IgnorePointer( + ignoring: _scrollOffset < 50, + child: AnimatedOpacity( + opacity: (_scrollOffset / 100).clamp(0.0, 1.0), + duration: const Duration(milliseconds: 150), + child: Container( + height: MediaQuery.of(context).padding.top + 58, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8), + Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.5), + Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0), + ], + stops: const [0.0, 0.3, 1.0], + ), ), ), ), ), ), - ), - // Back button (always visible) - Positioned( - top: 0, - left: 0, - child: DesktopAppBarHelper.buildAdjustedLeading( - AppBarBackButton( - style: BackButtonStyle.circular, - onPressed: () => Navigator.pop(context, _watchStateChanged), - ), - context: context, - )!, - ), - ], + // Back button (always visible) + Positioned( + top: 0, + left: 0, + child: DesktopAppBarHelper.buildAdjustedLeading( + AppBarBackButton( + style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), + ), + context: context, + )!, + ), + ], + ), ), ), - ), ); final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); @@ -2331,8 +2325,7 @@ class _MediaDetailScreenState extends State with WatchStateAw children: [ Text( actor.tag, - style: - Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -2558,7 +2551,10 @@ class _SeasonCardState extends State<_SeasonCard> { Row( mainAxisSize: MainAxisSize.min, children: [ - const Padding(padding: EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 14, fill: 1, color: Colors.amber)), + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon(Symbols.star_rounded, size: 14, fill: 1, color: Colors.amber), + ), const SizedBox(width: 3), Text( (widget.season.userRating! / 2) == (widget.season.userRating! / 2).truncateToDouble() @@ -2656,4 +2652,3 @@ class _SeasonCardState extends State<_SeasonCard> { ); } } - diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index d9092c39..7fd01c15 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -9,7 +9,6 @@ import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../main.dart'; import '../focus/focusable_wrapper.dart'; -import '../utils/global_key_utils.dart'; import '../focus/key_event_utils.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; @@ -21,7 +20,6 @@ import '../services/download_storage_service.dart'; import '../widgets/collapsible_text.dart'; import '../widgets/plex_optimized_image.dart'; import '../models/plex_metadata.dart'; -import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; import '../utils/video_player_navigation.dart'; import '../utils/formatters.dart'; @@ -31,6 +29,8 @@ import '../widgets/placeholder_container.dart'; import '../mixins/item_updatable.dart'; import '../mixins/watch_state_aware.dart'; import '../mixins/deletion_aware.dart'; +import '../mixins/mounted_set_state_mixin.dart'; +import '../mixins/server_bound_media_mixin.dart'; import '../utils/watch_state_notifier.dart'; import '../utils/deletion_notifier.dart'; import '../theme/mono_tokens.dart'; @@ -47,7 +47,7 @@ class SeasonDetailScreen extends StatefulWidget { } class _SeasonDetailScreenState extends State - with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware { + with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware, MountedSetStateMixin, ServerBoundMediaMixin { PlexClient? _client; @override @@ -61,27 +61,25 @@ class _SeasonDetailScreenState extends State bool _suppressNextBackKeyUp = false; bool _routeSubscribed = false; - String _toGlobalKey(String ratingKey, {String? serverId}) => - buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey); + @override + PlexMetadata get serverBoundMetadata => widget.season; - /// Calls [setState] only if the widget is still mounted. - void _setStateIfMounted(VoidCallback fn) { - if (mounted) setState(fn); - } + @override + bool get isServerBoundOffline => widget.isOffline; // WatchStateAware: watch all episode ratingKeys @override Set? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet(); @override - String? get watchStateServerId => widget.season.serverId; + String? get watchStateServerId => serverBoundServerId; @override Set? get watchedGlobalKeys { - final serverId = widget.season.serverId; + final serverId = serverBoundServerId; if (serverId == null) return null; - return _episodes.map((e) => _toGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); + return _episodes.map((e) => toServerBoundGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); } @override @@ -100,15 +98,15 @@ class _SeasonDetailScreenState extends State } @override - String? get deletionServerId => widget.season.serverId; + String? get deletionServerId => serverBoundServerId; @override Set? get deletionGlobalKeys { - final serverId = widget.season.serverId; + final serverId = serverBoundServerId; if (serverId == null) return null; - final keys = _episodes.map((e) => _toGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); - keys.add(_toGlobalKey(widget.season.ratingKey, serverId: serverId)); + final keys = _episodes.map((e) => toServerBoundGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); + keys.add(toServerBoundGlobalKey(widget.season.ratingKey, serverId: serverId)); return keys; } @@ -130,14 +128,6 @@ class _SeasonDetailScreenState extends State } } - /// Get the correct PlexClient for this season's server - PlexClient? _getClientForSeason(BuildContext context) { - if (widget.isOffline || widget.season.serverId == null) { - return null; - } - return context.getClientForServer(widget.season.serverId!); - } - @override void initState() { super.initState(); @@ -145,7 +135,7 @@ class _SeasonDetailScreenState extends State WidgetsBinding.instance.addPostFrameCallback((_) { // Capture keyboard mode once to avoid rebuild dependency when mode changes _initialKeyboardMode = InputModeTracker.isKeyboardMode(context); - _client = _getClientForSeason(context); + _client = getServerBoundClient(context); _loadEpisodes(); }); } @@ -165,12 +155,12 @@ class _SeasonDetailScreenState extends State // Episodes are automatically tagged with server info by PlexClient final episodes = await _client!.getChildren(widget.season.ratingKey); - _setStateIfMounted(() { + setStateIfMounted(() { _episodes = episodes; _isLoadingEpisodes = false; }); } catch (e) { - _setStateIfMounted(() { + setStateIfMounted(() { _isLoadingEpisodes = false; }); } @@ -583,7 +573,9 @@ class _EpisodeCardState extends State<_EpisodeCard> { CircularProgressIndicator( value: progress?.progressPercent, strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation(getMutedColor(Theme.of(context).colorScheme.primary)), + valueColor: AlwaysStoppedAnimation( + getMutedColor(Theme.of(context).colorScheme.primary), + ), ), ], ), diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 03f3f5d5..7b52b177 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -86,8 +86,11 @@ List _processOnDeckResponse(Map decoded, String s if (container == null || container['Metadata'] == null) return []; final allItems = (container['Metadata'] as List) - .map((json) => PlexMetadata.fromJsonWithImages(json as Map) - .copyWith(serverId: serverId, serverName: serverName)) + .map( + (json) => PlexMetadata.fromJsonWithImages( + json as Map, + ).copyWith(serverId: serverId, serverName: serverName), + ) .toList(); return allItems.where((item) => !item.isMusicContent).toList(); @@ -143,7 +146,11 @@ class PlexClient { /// Custom response decoder that handles malformed UTF-8 gracefully. /// Large responses are decoded in a background isolate to avoid ANR. - static FutureOr _lenientUtf8Decoder(List responseBytes, RequestOptions _, ResponseBody _a) { + static FutureOr _lenientUtf8Decoder( + List responseBytes, + RequestOptions requestOptions, + ResponseBody responseBody, + ) { if (responseBytes.length > 50 * 1024) { return compute(_decodeUtf8, responseBytes); } @@ -1196,11 +1203,10 @@ class PlexClient { /// Pass -1 to clear an existing rating Future rateItem(String ratingKey, double rating) { return _wrapBoolApiCall( - () => _dio.put('/:/rate', queryParameters: { - 'key': ratingKey, - 'identifier': 'com.plexapp.plugins.library', - 'rating': rating, - }), + () => _dio.put( + '/:/rate', + queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', 'rating': rating}, + ), 'Failed to rate item', ); } @@ -1335,10 +1341,7 @@ class PlexClient { /// This matches the official Plex client's home page layout. Future> getGlobalHubs({int limit = 10}) async { try { - final response = await _dio.get( - '/hubs', - queryParameters: {'count': limit, 'includeGuids': 1}, - ); + final response = await _dio.get('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final sid = serverId; final sname = serverName; return Isolate.run(() => _processHubResponse(response.data as Map, sid, sname)); @@ -1544,10 +1547,7 @@ class PlexClient { String? tagline, String? summary, }) { - final queryParams = { - 'type': typeNumber, - 'id': ratingKey, - }; + final queryParams = {'type': typeNumber, 'id': ratingKey}; void addField(String name, String? value) { if (value != null) { @@ -1602,10 +1602,7 @@ class PlexClient { () => _dio.put( '/library/metadata/$ratingKey/$setElement', data: bytes, - options: Options( - headers: {'Content-Length': bytes.length}, - contentType: 'application/octet-stream', - ), + options: Options(headers: {'Content-Length': bytes.length}, contentType: 'application/octet-stream'), ), 'Failed to upload artwork', ); diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 55f870c6..6b42143d 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -37,6 +37,13 @@ extension ProviderExtensions on BuildContext { return serverClient; } + /// Get PlexClient for a specific server ID, or null if unavailable. + PlexClient? tryGetClientForServer(String? serverId) { + if (serverId == null) return null; + final multiServerProvider = Provider.of(this, listen: false); + return multiServerProvider.getClientForServer(serverId); + } + /// Get PlexClient for a library /// Throws an exception if no client is available PlexClient getClientForLibrary(PlexLibrary library) { @@ -66,7 +73,7 @@ extension ProviderExtensions on BuildContext { if (isOffline || metadata.serverId == null) { return null; } - return getClientForServer(metadata.serverId!); + return tryGetClientForServer(metadata.serverId); } /// Get the first available client from connected servers diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 7b7c2d70..ced36e67 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -8,14 +8,13 @@ import 'package:flutter/services.dart'; import '../../focus/dpad_navigator.dart'; import '../../mpv/mpv.dart'; import '../../models/plex_media_info.dart'; -import '../../models/plex_media_version.dart'; import '../../models/plex_metadata.dart'; import '../../services/fullscreen_state_manager.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/formatters.dart'; import '../../i18n/strings.g.dart'; import '../../focus/focusable_wrapper.dart'; -import '../../services/shader_service.dart'; +import 'models/track_controls_state.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/video_controls_header.dart'; @@ -50,62 +49,18 @@ class DesktopVideoControls extends StatefulWidget { /// Called when user navigates up from timeline (to hide controls) final VoidCallback? onHideControls; - // Track chapter controls parameters - final List availableVersions; - final int selectedMediaIndex; - final int boxFitMode; - final int audioSyncOffset; - final int subtitleSyncOffset; - final bool isFullscreen; - final bool isAlwaysOnTop; - final VoidCallback? onTogglePIPMode; - final VoidCallback? onCycleBoxFitMode; - final VoidCallback? onToggleFullscreen; - final VoidCallback? onToggleAlwaysOnTop; - final Function(int)? onSwitchVersion; - final Function(AudioTrack)? onAudioTrackChanged; - final Function(SubtitleTrack)? onSubtitleTrackChanged; - final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; - final VoidCallback? onLoadSeekTimes; - final VoidCallback? onCancelAutoHide; - final VoidCallback? onStartAutoHide; - final void Function(String propertyName, int offset)? onSyncOffsetChanged; - final String serverId; + final TrackControlsState trackControlsState; final VoidCallback? onBack; - /// Whether the user can control playback (false in host-only mode for non-host). - final bool canControl; - /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; - final ShaderService? shaderService; - final VoidCallback? onShaderChanged; - /// Optional callback that returns thumbnail image bytes for a given timestamp. final Uint8List? Function(Duration time)? thumbnailDataBuilder; - /// Whether this is a live TV stream - final bool isLive; - /// Channel name for live TV display final String? liveChannelName; - /// Whether ambient lighting is enabled (passed to settings sheet) - final bool isAmbientLightingEnabled; - - /// Called to toggle ambient lighting (passed to settings sheet) - final VoidCallback? onToggleAmbientLighting; - - /// Whether subtitles are currently visible (false = hidden via sub-visibility toggle) - final bool subtitlesVisible; - - /// Whether to show the queue button - final bool showQueueButton; - - /// Callback when a queue item is selected - final Function(PlexMetadata)? onQueueItemSelected; - const DesktopVideoControls({ super.key, required this.player, @@ -126,39 +81,11 @@ class DesktopVideoControls extends StatefulWidget { this.onFocusActivity, this.onRequestPlayPauseFocus, this.onHideControls, - this.availableVersions = const [], - this.selectedMediaIndex = 0, - this.boxFitMode = 0, - this.audioSyncOffset = 0, - this.subtitleSyncOffset = 0, - this.isFullscreen = false, - this.isAlwaysOnTop = false, - this.onTogglePIPMode, - this.onCycleBoxFitMode, - this.onToggleFullscreen, - this.onToggleAlwaysOnTop, - this.onSwitchVersion, - this.onAudioTrackChanged, - this.onSubtitleTrackChanged, - this.onSecondarySubtitleTrackChanged, - this.onLoadSeekTimes, - this.onCancelAutoHide, - this.onStartAutoHide, - this.onSyncOffsetChanged, - this.serverId = '', + this.trackControlsState = const TrackControlsState(), this.onBack, - this.canControl = true, this.hasFirstFrame, - this.shaderService, - this.onShaderChanged, this.thumbnailDataBuilder, - this.isLive = false, this.liveChannelName, - this.isAmbientLightingEnabled = false, - this.onToggleAmbientLighting, - this.subtitlesVisible = true, - this.showQueueButton = false, - this.onQueueItemSelected, }); @override @@ -166,6 +93,10 @@ class DesktopVideoControls extends StatefulWidget { } class DesktopVideoControlsState extends State { + TrackControlsState get _trackControlsState => widget.trackControlsState; + bool get _canControl => _trackControlsState.canControl; + bool get _isLive => _trackControlsState.isLive; + // Focus nodes for playback control buttons late final FocusNode _prevItemFocusNode; late final FocusNode _prevChapterFocusNode; @@ -370,7 +301,7 @@ class DesktopVideoControlsState extends State { // LEFT/RIGHT for smooth scrubbing with progressive acceleration if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { // Ignore seeking if user cannot control - if (!widget.canControl) return KeyEventResult.handled; + if (!_canControl) return KeyEventResult.handled; if (duration.inMilliseconds <= 0) return KeyEventResult.handled; @@ -455,7 +386,7 @@ class DesktopVideoControlsState extends State { onBack: widget.onBack, ), ), - if (widget.isLive) ...[ + if (_isLive) ...[ const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -474,13 +405,13 @@ class DesktopVideoControlsState extends State { } Widget _buildBottomControlsContent(BuildContext _, {required bool hasFrame}) { - final canInteract = widget.canControl && hasFrame; + final canInteract = _canControl && hasFrame; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( children: [ // Row 1: Timeline with time indicators (hidden for live TV) - if (!widget.isLive) ...[ + if (!_isLive) ...[ VideoTimelineBar( player: widget.player, chapters: widget.chapters, @@ -499,16 +430,16 @@ class DesktopVideoControlsState extends State { // Row 2: Playback controls and options Row( children: [ - if (!widget.isLive) ...[ + if (!_isLive) ...[ // Previous item Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _prevItemFocusNode, index: 0, icon: Symbols.skip_previous_rounded, - color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onPrevious : null, + color: widget.onPrevious != null && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl ? widget.onPrevious : null, semanticLabel: t.videoControls.previousButton, ), ), @@ -519,15 +450,13 @@ class DesktopVideoControlsState extends State { builder: (context, posSnapshot) { final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); return Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _prevChapterFocusNode, index: 1, icon: Symbols.fast_rewind_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty - ? widget.onSeekToPreviousChapter - : null, + color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null, semanticLabel: t.videoControls.previousChapterButton, tooltip: prevLabel, ), @@ -536,19 +465,19 @@ class DesktopVideoControlsState extends State { ), // Skip backward Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _skipBackFocusNode, index: 2, icon: widget.getReplayIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekBackward : null, + onPressed: _canControl ? widget.onSeekBackward : null, semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), ), ), ], // Play/Pause Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: PlayPauseStreamBuilder( player: widget.player, builder: (context, isPlaying) { @@ -557,7 +486,7 @@ class DesktopVideoControlsState extends State { index: 3, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 32, - onPressed: widget.canControl + onPressed: _canControl ? () { if (isPlaying) { widget.player.pause(); @@ -571,15 +500,15 @@ class DesktopVideoControlsState extends State { }, ), ), - if (!widget.isLive) ...[ + if (!_isLive) ...[ // Skip forward Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _skipForwardFocusNode, index: 4, icon: widget.getForwardIcon(widget.seekTimeSmall), - onPressed: widget.canControl ? widget.onSeekForward : null, + onPressed: _canControl ? widget.onSeekForward : null, semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), ), ), @@ -590,13 +519,13 @@ class DesktopVideoControlsState extends State { builder: (context, posSnapshot) { final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); return Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _nextChapterFocusNode, index: 5, icon: Symbols.fast_forward_rounded, - color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, + color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, semanticLabel: t.videoControls.nextChapterButton, tooltip: nextLabel, ), @@ -605,19 +534,19 @@ class DesktopVideoControlsState extends State { ), // Next item Opacity( - opacity: widget.canControl ? 1.0 : 0.5, + opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( focusNode: _nextItemFocusNode, index: 6, icon: Symbols.skip_next_rounded, - color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, - onPressed: widget.canControl ? widget.onNext : null, + color: widget.onNext != null && _canControl ? Colors.white : Colors.white54, + onPressed: _canControl ? widget.onNext : null, semanticLabel: t.videoControls.nextButton, ), ), ], // Finish time (hidden for live TV and when too narrow to fit) - if (widget.isLive) + if (_isLive) const Spacer() else Expanded( @@ -684,39 +613,10 @@ class DesktopVideoControlsState extends State { player: widget.player, chapters: widget.chapters, chaptersLoaded: widget.chaptersLoaded, - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: widget.boxFitMode, - audioSyncOffset: widget.audioSyncOffset, - subtitleSyncOffset: widget.subtitleSyncOffset, - isRotationLocked: false, // Desktop doesn't have rotation lock - isFullscreen: widget.isFullscreen, - isAlwaysOnTop: widget.isAlwaysOnTop, - serverId: widget.serverId, - onTogglePIPMode: widget.onTogglePIPMode, - onCycleBoxFitMode: widget.onCycleBoxFitMode, - onToggleFullscreen: widget.onToggleFullscreen, - onToggleAlwaysOnTop: widget.onToggleAlwaysOnTop, - onSwitchVersion: widget.onSwitchVersion, - onAudioTrackChanged: widget.onAudioTrackChanged, - onSubtitleTrackChanged: widget.onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, - onLoadSeekTimes: widget.onLoadSeekTimes, - onCancelAutoHide: widget.onCancelAutoHide, - onStartAutoHide: widget.onStartAutoHide, - onSyncOffsetChanged: widget.onSyncOffsetChanged, + trackControlsState: _trackControlsState, focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, - canControl: widget.canControl, - isLive: widget.isLive, - subtitlesVisible: widget.subtitlesVisible, - showQueueButton: widget.showQueueButton, - onQueueItemSelected: widget.onQueueItemSelected, - shaderService: widget.shaderService, - onShaderChanged: widget.onShaderChanged, - isAmbientLightingEnabled: widget.isAmbientLightingEnabled, - onToggleAmbientLighting: widget.onToggleAmbientLighting, ), ], ), diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart new file mode 100644 index 00000000..44557201 --- /dev/null +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../../models/plex_media_version.dart'; +import '../../../models/plex_metadata.dart'; +import '../../../mpv/mpv.dart'; +import '../../../services/shader_service.dart'; + +/// Immutable configuration for track/chapter control widgets. +class TrackControlsState { + final List availableVersions; + final int selectedMediaIndex; + final int boxFitMode; + final int audioSyncOffset; + final int subtitleSyncOffset; + final bool isRotationLocked; + final bool isFullscreen; + final bool isAlwaysOnTop; + final VoidCallback? onTogglePIPMode; + final VoidCallback? onCycleBoxFitMode; + final VoidCallback? onToggleRotationLock; + final VoidCallback? onToggleFullscreen; + final VoidCallback? onToggleAlwaysOnTop; + final Function(int)? onSwitchVersion; + final Function(AudioTrack)? onAudioTrackChanged; + final Function(SubtitleTrack)? onSubtitleTrackChanged; + final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; + final VoidCallback? onLoadSeekTimes; + final VoidCallback? onCancelAutoHide; + final VoidCallback? onStartAutoHide; + final void Function(String propertyName, int offset)? onSyncOffsetChanged; + final String serverId; + final ShaderService? shaderService; + final VoidCallback? onShaderChanged; + final bool isAmbientLightingEnabled; + final VoidCallback? onToggleAmbientLighting; + final bool canControl; + final bool isLive; + final bool subtitlesVisible; + final bool showQueueButton; + final Function(PlexMetadata)? onQueueItemSelected; + + const TrackControlsState({ + this.availableVersions = const [], + this.selectedMediaIndex = 0, + this.boxFitMode = 0, + this.audioSyncOffset = 0, + this.subtitleSyncOffset = 0, + this.isRotationLocked = false, + this.isFullscreen = false, + this.isAlwaysOnTop = false, + this.onTogglePIPMode, + this.onCycleBoxFitMode, + this.onToggleRotationLock, + this.onToggleFullscreen, + this.onToggleAlwaysOnTop, + this.onSwitchVersion, + this.onAudioTrackChanged, + this.onSubtitleTrackChanged, + this.onSecondarySubtitleTrackChanged, + this.onLoadSeekTimes, + this.onCancelAutoHide, + this.onStartAutoHide, + this.onSyncOffsetChanged, + this.serverId = '', + this.shaderService, + this.onShaderChanged, + this.isAmbientLightingEnabled = false, + this.onToggleAmbientLighting, + this.canControl = true, + this.isLive = false, + this.subtitlesVisible = true, + this.showQueueButton = false, + this.onQueueItemSelected, + }); +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 3a6de3db..b599498b 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -37,12 +37,7 @@ class ChapterSheet extends StatefulWidget { class _ChapterSheetState extends State { /// Get the PlexClient for chapters, or null if unavailable (offline mode) PlexClient? _tryGetClientForChapters(BuildContext context) { - if (widget.serverId == null) return null; - try { - return context.getClientForServer(widget.serverId!); - } catch (_) { - return null; - } + return context.tryGetClientForServer(widget.serverId); } @override @@ -113,7 +108,9 @@ class _ChapterSheetState extends State { child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.primary, width: 2), + ), ), ), ), @@ -131,7 +128,9 @@ class _ChapterSheetState extends State { subtitle: Text( formatDurationTimestamp(chapter.startTime), style: TextStyle( - color: isCurrentChapter ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted, + color: isCurrentChapter + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : tokens(context).textMuted, fontSize: 12, ), ), diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 5af679e3..7bdd4401 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -132,11 +132,6 @@ class QueueSheet extends StatelessWidget { } static dynamic _tryGetClient(BuildContext context, PlexMetadata item) { - if (item.serverId == null) return null; - try { - return context.getClientForServer(item.serverId!); - } catch (_) { - return null; - } + return context.tryGetClientForServer(item.serverId); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 8c9b1d5c..992a434a 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -46,6 +46,7 @@ import 'icons.dart'; import '../../utils/app_logger.dart'; import '../../i18n/strings.g.dart'; import '../../focus/input_mode_tracker.dart'; +import 'models/track_controls_state.dart'; import 'widgets/track_chapter_controls.dart'; import 'widgets/performance_overlay/performance_overlay.dart'; import 'mobile_video_controls.dart'; @@ -991,13 +992,11 @@ class _PlexVideoControlsState extends State with WindowListen return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers); } - Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { - final playbackState = context.watch(); - - return TrackChapterControls( - player: widget.player, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, + TrackControlsState _buildTrackControlsState({ + required PlaybackStateProvider playbackState, + required VoidCallback? onToggleAlwaysOnTop, + }) { + return TrackControlsState( availableVersions: widget.availableVersions, selectedMediaIndex: widget.selectedMediaIndex, boxFitMode: widget.boxFitMode, @@ -1005,17 +1004,18 @@ class _PlexVideoControlsState extends State with WindowListen subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, isFullscreen: _isFullscreen, + isAlwaysOnTop: _isAlwaysOnTop, onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) ? widget.onTogglePIPMode : null, onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null, onToggleRotationLock: _toggleRotationLock, onToggleFullscreen: _toggleFullscreen, + onToggleAlwaysOnTop: onToggleAlwaysOnTop, onSwitchVersion: _switchMediaVersion, onAudioTrackChanged: widget.onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, - subtitlesVisible: _subtitlesVisible, onLoadSeekTimes: () async { if (mounted) { await _loadSeekTimes(); @@ -1033,15 +1033,31 @@ class _PlexVideoControlsState extends State with WindowListen }); }, serverId: widget.metadata.serverId ?? '', - canControl: widget.canControl, - isLive: widget.isLive, - showQueueButton: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - hideChaptersAndQueue: hideChaptersAndQueue, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null, + canControl: widget.canControl, + isLive: widget.isLive, + subtitlesVisible: _subtitlesVisible, + showQueueButton: playbackState.isQueueActive, + onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, + ); + } + + Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { + final playbackState = context.watch(); + final trackControlsState = _buildTrackControlsState( + playbackState: playbackState, + onToggleAlwaysOnTop: _toggleAlwaysOnTop, + ); + + return TrackChapterControls( + player: widget.player, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + trackControlsState: trackControlsState, + hideChaptersAndQueue: hideChaptersAndQueue, ); } @@ -2030,11 +2046,11 @@ class _PlexVideoControlsState extends State with WindowListen } Widget _buildDesktopControlsListener() { - final pipMode = (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) - ? widget.onTogglePIPMode - : null; - final boxFitMode = widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null; final playbackState = context.watch(); + final trackControlsState = _buildTrackControlsState( + playbackState: playbackState, + onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, + ); return Listener( behavior: HitTestBehavior.translucent, @@ -2058,51 +2074,11 @@ class _PlexVideoControlsState extends State with WindowListen getForwardIcon: getForwardIcon, onFocusActivity: _restartHideTimerIfPlaying, onHideControls: _hideControlsFromKeyboard, - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: widget.boxFitMode, - audioSyncOffset: _audioSyncOffset, - subtitleSyncOffset: _subtitleSyncOffset, - isFullscreen: _isFullscreen, - isAlwaysOnTop: _isAlwaysOnTop, - onTogglePIPMode: pipMode, - onCycleBoxFitMode: boxFitMode, - onToggleFullscreen: _toggleFullscreen, - onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop, - onSwitchVersion: _switchMediaVersion, - onAudioTrackChanged: widget.onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, - subtitlesVisible: _subtitlesVisible, - onLoadSeekTimes: () async { - if (mounted) { - await _loadSeekTimes(); - } - }, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onSyncOffsetChanged: (propertyName, offset) { - setState(() { - if (propertyName == 'sub-delay') { - _subtitleSyncOffset = offset; - } else { - _audioSyncOffset = offset; - } - }); - }, - serverId: widget.metadata.serverId ?? '', + trackControlsState: trackControlsState, onBack: widget.onBack, - canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, - showQueueButton: playbackState.isQueueActive, - onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - shaderService: widget.shaderService, - onShaderChanged: widget.onShaderChanged, thumbnailDataBuilder: widget.thumbnailDataBuilder, - isLive: widget.isLive, liveChannelName: widget.liveChannelName, - isAmbientLightingEnabled: widget.isAmbientLightingEnabled, - onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null, ), ); } diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index 586308ce..d3cbac76 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -65,12 +65,7 @@ class _ContentStripState extends State { } PlexClient? _tryGetClient(BuildContext context, String? serverId) { - if (serverId == null) return null; - try { - return context.getClientForServer(serverId); - } catch (_) { - return null; - } + return context.tryGetClientForServer(serverId); } void _autoScrollTo(ScrollController controller, int index, {bool force = false}) { @@ -93,10 +88,7 @@ class _ContentStripState extends State { children: [ if (_hasBothTabs) _buildTabBar(), const SizedBox(height: 8), - SizedBox( - height: 106, - child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip(), - ), + SizedBox(height: 106, child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip()), ], ), ), @@ -130,11 +122,7 @@ class _ContentStripState extends State { ), ), const SizedBox(height: 4), - Container( - height: 2, - width: 40, - color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent, - ), + Container(height: 2, width: 40, color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent), ], ), ); @@ -152,7 +140,8 @@ class _ContentStripState extends State { for (int i = 0; i < widget.chapters.length; i++) { final chapter = widget.chapters[i]; final startMs = chapter.startTimeOffset ?? 0; - final endMs = chapter.endTimeOffset ?? + final endMs = + chapter.endTimeOffset ?? (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); if (currentPositionMs >= startMs && currentPositionMs < endMs) { currentChapterIndex = i; @@ -232,7 +221,7 @@ class _ContentStripState extends State { PlexClient? client; if (item.serverId != null) { try { - client = context.getClientForServer(item.serverId!); + client = context.tryGetClientForServer(item.serverId); } catch (_) {} } @@ -293,7 +282,8 @@ class _ContentStripState extends State { children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(6)), - child: thumbnail ?? + child: + thumbnail ?? Container( color: Colors.white10, child: const Center( @@ -306,7 +296,9 @@ class _ContentStripState extends State { child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(6)), - border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.primary, width: 2), + ), ), ), ), @@ -329,7 +321,9 @@ class _ContentStripState extends State { Text( subtitle, style: TextStyle( - color: isCurrent ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted, + color: isCurrent + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : tokens(context).textMuted, fontSize: 10, ), maxLines: 1, diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 5bd28451..65367f50 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -13,6 +13,7 @@ import '../../../utils/platform_detector.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../models/plex_metadata.dart'; +import '../models/track_controls_state.dart'; import '../sheets/chapter_sheet.dart'; import '../sheets/queue_sheet.dart'; import '../sheets/track_sheet.dart'; @@ -27,36 +28,7 @@ class TrackChapterControls extends StatelessWidget { final Player player; final List chapters; final bool chaptersLoaded; - final List availableVersions; - final int selectedMediaIndex; - final int boxFitMode; - final int audioSyncOffset; - final int subtitleSyncOffset; - final bool isRotationLocked; - final bool isFullscreen; - final bool isAlwaysOnTop; - final VoidCallback? onTogglePIPMode; - final VoidCallback? onCycleBoxFitMode; - final VoidCallback? onToggleRotationLock; - final VoidCallback? onToggleFullscreen; - final VoidCallback? onToggleAlwaysOnTop; - final Function(int)? onSwitchVersion; - final Function(AudioTrack)? onAudioTrackChanged; - final Function(SubtitleTrack)? onSubtitleTrackChanged; - final Function(SubtitleTrack)? onSecondarySubtitleTrackChanged; - final VoidCallback? onLoadSeekTimes; - final VoidCallback? onCancelAutoHide; - final VoidCallback? onStartAutoHide; - final void Function(String propertyName, int offset)? onSyncOffsetChanged; - final String serverId; - final ShaderService? shaderService; - final VoidCallback? onShaderChanged; - - /// Whether ambient lighting is enabled (passed to settings sheet) - final bool isAmbientLightingEnabled; - - /// Called to toggle ambient lighting (passed to settings sheet) - final VoidCallback? onToggleAmbientLighting; + final TrackControlsState trackControlsState; /// List of FocusNodes for the buttons (passed from parent for navigation) final List? focusNodes; @@ -67,21 +39,6 @@ class TrackChapterControls extends StatelessWidget { /// Called to navigate left from the first button final VoidCallback? onNavigateLeft; - /// Whether the user can control playback (false in host-only mode for non-host). - final bool canControl; - - /// Whether this is a live TV stream (hides speed settings). - final bool isLive; - - /// Whether subtitles are currently visible (false = hidden via sub-visibility toggle) - final bool subtitlesVisible; - - /// Whether to show the queue button - final bool showQueueButton; - - /// Callback when a queue item is selected - final Function(PlexMetadata)? onQueueItemSelected; - /// Whether to hide the chapters and queue buttons (mobile uses content strip instead) final bool hideChaptersAndQueue; @@ -90,43 +47,45 @@ class TrackChapterControls extends StatelessWidget { required this.player, required this.chapters, required this.chaptersLoaded, - required this.availableVersions, - required this.selectedMediaIndex, - required this.boxFitMode, - required this.audioSyncOffset, - required this.subtitleSyncOffset, - required this.isRotationLocked, - required this.isFullscreen, - required this.serverId, - this.isAlwaysOnTop = false, - this.onTogglePIPMode, - this.onCycleBoxFitMode, - this.onToggleRotationLock, - this.onToggleFullscreen, - this.onToggleAlwaysOnTop, - this.onSwitchVersion, - this.onAudioTrackChanged, - this.onSubtitleTrackChanged, - this.onSecondarySubtitleTrackChanged, - this.onLoadSeekTimes, - this.onCancelAutoHide, - this.onStartAutoHide, - this.onSyncOffsetChanged, + required this.trackControlsState, this.focusNodes, this.onFocusChange, this.onNavigateLeft, - this.canControl = true, - this.isLive = false, - this.subtitlesVisible = true, - this.showQueueButton = false, - this.onQueueItemSelected, this.hideChaptersAndQueue = false, - this.shaderService, - this.onShaderChanged, - this.isAmbientLightingEnabled = false, - this.onToggleAmbientLighting, }); + List get availableVersions => trackControlsState.availableVersions; + int get selectedMediaIndex => trackControlsState.selectedMediaIndex; + int get boxFitMode => trackControlsState.boxFitMode; + int get audioSyncOffset => trackControlsState.audioSyncOffset; + int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset; + bool get isRotationLocked => trackControlsState.isRotationLocked; + bool get isFullscreen => trackControlsState.isFullscreen; + bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop; + VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode; + VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode; + VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock; + VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen; + VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop; + Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion; + Function(AudioTrack)? get onAudioTrackChanged => trackControlsState.onAudioTrackChanged; + Function(SubtitleTrack)? get onSubtitleTrackChanged => trackControlsState.onSubtitleTrackChanged; + Function(SubtitleTrack)? get onSecondarySubtitleTrackChanged => trackControlsState.onSecondarySubtitleTrackChanged; + VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes; + VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; + VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; + void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged; + String get serverId => trackControlsState.serverId; + ShaderService? get shaderService => trackControlsState.shaderService; + VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged; + bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled; + VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting; + bool get canControl => trackControlsState.canControl; + bool get isLive => trackControlsState.isLive; + bool get subtitlesVisible => trackControlsState.subtitlesVisible; + bool get showQueueButton => trackControlsState.showQueueButton; + Function(PlexMetadata)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; + /// Handle key event for button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { if (!event.isActionable) { diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 7f55c8e7..869df956 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; }; 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; }; 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; }; + B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; }; 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; }; 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; 6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; }; @@ -88,6 +89,7 @@ 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; + B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; }; 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; }; @@ -218,6 +220,7 @@ 6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = { isa = PBXGroup; children = ( + B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */, 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */, 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */, 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */, @@ -469,6 +472,7 @@ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */, 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 96563a0c..fd806194 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -1,87 +1,13 @@ import Cocoa import Libmpv -/// Protocol for receiving player events -protocol MpvPlayerDelegate: AnyObject { - func onPropertyChange(name: String, value: Any?) - func onEvent(name: String, data: [String: Any]?) -} +/// Core MPV player using Metal rendering. +class MpvPlayerCore: MpvPlayerCoreBase { -// Workaround for MoltenVK problems that cause flicker -// https://github.com/mpv-player/mpv/pull/13651 -private class MetalLayer: CAMetalLayer { - override var drawableSize: CGSize { - get { return super.drawableSize } - set { - // Allow .zero (auto-derive from bounds) or valid sizes > 1x1 - if newValue == .zero || (Int(newValue.width) > 1 && Int(newValue.height) > 1) { - super.drawableSize = newValue - } - } - } - - // Fix for target-colorspace-hint - needs main thread for EDR - override var wantsExtendedDynamicRangeContent: Bool { - get { return super.wantsExtendedDynamicRangeContent } - set { - if Thread.isMainThread { - super.wantsExtendedDynamicRangeContent = newValue - } else { - DispatchQueue.main.async { - super.wantsExtendedDynamicRangeContent = newValue - } - } - } - } -} - -/// Safely convert a C string to Swift String with UTF-8 validation. -/// Falls back to Latin-1 decoding if the bytes are not valid UTF-8. -/// mpv does not guarantee UTF-8 for log messages, error strings, or -/// system-encoded paths — sending invalid UTF-8 through Flutter's -/// StandardMessageCodec causes FormatException crashes. -private func safeString(_ cstr: UnsafePointer) -> String { - if let s = String(validatingUTF8: cstr) { - return s - } - // Latin-1 fallback: interpret each byte as its Unicode scalar - let len = strlen(cstr) - let buf = UnsafeBufferPointer(start: UnsafeRawPointer(cstr).assumingMemoryBound(to: UInt8.self), count: len) - return String(buf.map { Character(Unicode.Scalar($0)) }) -} - -/// Core MPV player using Metal rendering -class MpvPlayerCore: NSObject { - - // MARK: - Properties - - private var metalLayer: MetalLayer? - private var mpv: OpaquePointer? private weak var window: NSWindow? - private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated) private var playbackActivity: NSObjectProtocol? - - weak var delegate: MpvPlayerDelegate? - - private(set) var isInitialized = false - - // PiP state - var isPipActive = false - - // HDR settings - private var hdrEnabled = true // User preference for HDR - private var lastSigPeak: Double = 0.0 // Last known sig-peak for re-evaluation - - // Background occlusion state — tracks if we hid the layer for occlusion private var layerHiddenForOcclusion = false - // Async command tracking to prevent UI blocking - private var pendingCommands: [UInt64: (Result) -> Void] = [:] - private var pendingCommandsLock = NSLock() - private var nextRequestId: UInt64 = 1 - - // MARK: - Initialization - func initialize(in window: NSWindow) -> Bool { guard !isInitialized else { print("[MpvPlayerCore] Already initialized") @@ -95,8 +21,7 @@ class MpvPlayerCore: NSObject { self.window = window - // Create Metal layer for video rendering - let layer = MetalLayer() + let layer = MpvMetalLayer() layer.frame = contentView.bounds if let screen = window.screen ?? NSScreen.main { layer.contentsScale = screen.backingScaleFactor @@ -108,13 +33,11 @@ class MpvPlayerCore: NSObject { metalLayer = layer - // Ensure contentView has a layer and add our Metal layer contentView.wantsLayer = true contentView.layer?.addSublayer(layer) print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)") - // Initialize MPV with this Metal layer guard setupMpv() else { print("[MpvPlayerCore] Failed to setup MPV") layer.removeFromSuperlayer() @@ -122,235 +45,54 @@ class MpvPlayerCore: NSObject { return false } - // Register for fullscreen notifications to avoid MoltenVK swapchain crash - let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(windowWillEnterFullScreen), - name: NSWindow.willEnterFullScreenNotification, object: window) - nc.addObserver(self, selector: #selector(windowDidEnterFullScreen), - name: NSWindow.didEnterFullScreenNotification, object: window) - nc.addObserver(self, selector: #selector(windowWillExitFullScreen), - name: NSWindow.willExitFullScreenNotification, object: window) - nc.addObserver(self, selector: #selector(windowDidExitFullScreen), - name: NSWindow.didExitFullScreenNotification, object: window) - nc.addObserver(self, selector: #selector(windowOcclusionDidChange), - name: NSWindow.didChangeOcclusionStateNotification, object: window) + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(windowWillEnterFullScreen), + name: NSWindow.willEnterFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowDidEnterFullScreen), + name: NSWindow.didEnterFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowWillExitFullScreen), + name: NSWindow.willExitFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowDidExitFullScreen), + name: NSWindow.didExitFullScreenNotification, + object: window + ) + center.addObserver( + self, + selector: #selector(windowOcclusionDidChange), + name: NSWindow.didChangeOcclusionStateNotification, + object: window + ) isInitialized = true print("[MpvPlayerCore] Initialized successfully with MPV") return true } - // MARK: - Fullscreen Transition Handling - - @objc private func windowWillEnterFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] willEnterFullScreen — disabling video output") - mpv_set_property_string(mpv, "vid", "no") - } - - @objc private func windowDidEnterFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] didEnterFullScreen — re-enabling video output") - mpv_set_property_string(mpv, "vid", "auto") - } - - @objc private func windowWillExitFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] willExitFullScreen — disabling video output") - mpv_set_property_string(mpv, "vid", "no") - } - - @objc private func windowDidExitFullScreen(_ notification: Notification) { - guard mpv != nil, !isPipActive else { return } - print("[MpvPlayerCore] didExitFullScreen — re-enabling video output") - mpv_set_property_string(mpv, "vid", "auto") - } - - // MARK: - Window Occlusion Handling - - @objc private func windowOcclusionDidChange(_ notification: Notification) { - guard let layer = metalLayer, mpv != nil, !isPipActive else { return } - - let isVisible = window?.occlusionState.contains(.visible) ?? true - - if !isVisible && !layerHiddenForOcclusion { - print("[MpvPlayerCore] Window occluded — hiding Metal layer") - layer.isHidden = true - layerHiddenForOcclusion = true - } else if isVisible && layerHiddenForOcclusion { - print("[MpvPlayerCore] Window visible — showing Metal layer") - layerHiddenForOcclusion = false - layer.isHidden = false - } - } - - private func setupMpv() -> Bool { - guard let metalLayer = metalLayer else { return false } - - mpv = mpv_create() - guard mpv != nil else { - print("[MpvPlayerCore] Failed to create MPV context") - return false - } - - // Logging - #if DEBUG - checkError(mpv_request_log_messages(mpv, "info")) - #else - checkError(mpv_request_log_messages(mpv, "warn")) - #endif - - // Set the Metal layer as the render target (must use local var for &) - var layer = metalLayer - checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer)) - - // Video output settings for Metal/Vulkan - checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) - 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")) + override func configurePlatformMpvOptions() { + guard let mpv else { return } checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) - checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "mailbox")) - - // Initialize MPV - let initResult = mpv_initialize(mpv) - if initResult < 0 { - 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())) - - // 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 } - // MARK: - MPV Properties and Commands - - func setLogLevel(_ level: String) { - guard mpv != nil else { return } - mpv_request_log_messages(mpv, level) - } - - 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) - defer { mpv_free(cstr) } - return cstr.map { String(cString: $0) } - } - - func observeProperty(_ name: String, format: String) { - guard mpv != nil else { return } - - let mpvFormat: mpv_format - switch format { - case "double": mpvFormat = MPV_FORMAT_DOUBLE - case "flag": mpvFormat = MPV_FORMAT_FLAG - case "node": mpvFormat = MPV_FORMAT_NODE - case "string": mpvFormat = MPV_FORMAT_STRING - default: return - } - - mpv_observe_property(mpv, 0, name, mpvFormat) - } - - func command(_ args: [String]) { - guard mpv != nil, !args.isEmpty else { return } - command(args[0], args: Array(args.dropFirst())) - } - - /// Execute an MPV command asynchronously to prevent UI blocking. - /// Uses mpv_command_async which returns immediately; the completion is called - /// when MPV_EVENT_COMMAND_REPLY is received. - func commandAsync(_ args: [String], completion: @escaping (Result) -> Void) { - guard let mpv = mpv, !args.isEmpty else { - completion(.success(())) - return - } - - // Generate unique request ID - pendingCommandsLock.lock() - let requestId = nextRequestId - nextRequestId += 1 - pendingCommands[requestId] = completion - pendingCommandsLock.unlock() - - // Build array of C strings for mpv_command_async - var cargs: [UnsafeMutablePointer?] = args.map { strdup($0) } - cargs.append(nil) // null-terminate - - // mpv_command_async returns immediately - cargs.withUnsafeBufferPointer { buffer in - var constPtrs = buffer.map { UnsafePointer($0) } - let result = mpv_command_async(mpv, requestId, &constPtrs) - if result < 0 { - // Command submission failed, complete immediately with error - pendingCommandsLock.lock() - if let pending = pendingCommands.removeValue(forKey: requestId) { - pendingCommandsLock.unlock() - let error = NSError(domain: "mpv", code: Int(result), - userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(result))]) - DispatchQueue.main.async { pending(.failure(error)) } - } else { - pendingCommandsLock.unlock() - } - } - } - - // Free the C strings - for ptr in cargs { - free(ptr) - } - } - - // MARK: - PiP Support - - /// The Metal layer used for video rendering, exposed for PiP. - /// PiP moves this layer to its own window; mpv continues rendering to it. var videoLayer: CAMetalLayer? { metalLayer } - /// Re-attach the Metal layer to the main window after PiP exits. func reattachMetalLayer() { - guard let metalLayer = metalLayer, let contentView = window?.contentView else { return } + guard let metalLayer, let contentView = window?.contentView else { return } + if metalLayer.superlayer == nil { contentView.wantsLayer = true contentView.layer?.insertSublayer(metalLayer, at: 0) @@ -363,69 +105,40 @@ class MpvPlayerCore: NSObject { ) } } + print("[MpvPlayerCore] Metal layer reattached to window") } - /// Force a redraw (useful after PiP exit when paused). - /// Uses a seek to the current position to trigger a frame render. func forceDraw() { command(["seek", "0", "relative+exact"]) } - /// Whether mpv is currently paused - var isPaused: Bool { - guard let mpv = mpv else { return true } - var flag: Int32 = 0 - mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &flag) - return flag != 0 - } - - /// Current playback duration in seconds - var duration: Double { - guard let mpv = mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "duration", MPV_FORMAT_DOUBLE, &value) - return value - } - - /// Current playback time in seconds - var timePos: Double { - guard let mpv = mpv else { return 0 } - var value: Double = 0 - mpv_get_property(mpv, "time-pos", MPV_FORMAT_DOUBLE, &value) - return value - } - - // MARK: - Visibility - func setVisible(_ visible: Bool) { - guard let layer = metalLayer, !isPipActive else { return } + guard let metalLayer, !isPipActive else { return } if visible { - // Re-insert after background layer but before Flutter control views - layer.removeFromSuperlayer() + metalLayer.removeFromSuperlayer() if let superlayer = window?.contentView?.layer { - superlayer.insertSublayer(layer, at: 0) + superlayer.insertSublayer(metalLayer, at: 0) } beginPlaybackActivity() } else { endPlaybackActivity() } - layer.isHidden = !visible + metalLayer.isHidden = !visible print("[MpvPlayerCore] setVisible(\(visible))") } func updateFrame(_ frame: CGRect? = nil) { - guard let metalLayer = metalLayer, !isPipActive else { return } + guard let metalLayer, !isPipActive else { return } - if let frame = frame { + if let frame { metalLayer.frame = frame } else if let contentView = window?.contentView { metalLayer.frame = contentView.bounds } - // Update drawable size for proper scaling if let screen = window?.screen ?? NSScreen.main { let scale = screen.backingScaleFactor metalLayer.drawableSize = CGSize( @@ -437,220 +150,75 @@ class MpvPlayerCore: NSObject { print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") } - // MARK: - Private Helpers + override func updateEDRMode(sigPeak: Double) { + guard let metalLayer else { return } - private func command(_ cmd: String, args: [String] = []) { - guard mpv != nil else { return } - - // Build array of C strings for mpv_command - var cargs: [UnsafeMutablePointer?] = ([cmd] + args).map { strdup($0) } - cargs.append(nil) // null-terminate - defer { - for ptr in cargs { - free(ptr) - } - } - - // mpv_command expects UnsafePointer, use withUnsafeBufferPointer for the conversion - cargs.withUnsafeBufferPointer { buffer in - var constPtrs = buffer.map { UnsafePointer($0) } - _ = mpv_command(mpv, &constPtrs) - } - } - - private func readEvents() { - queue.async { [weak self] in - guard let self = self, let mpv = self.mpv else { return } - - while true { - let event = mpv_wait_event(mpv, 0) - guard let eventPtr = event else { break } - - if eventPtr.pointee.event_id == MPV_EVENT_NONE { - break - } - - self.handleEvent(eventPtr.pointee) - } - } - } - - private func handleEvent(_ event: mpv_event) { - switch event.event_id { - case MPV_EVENT_PROPERTY_CHANGE: - guard let data = event.data else { break } - let property = data.assumingMemoryBound(to: mpv_event_property.self).pointee - let name = String(cString: property.name) - handlePropertyChange(name: name, property: property) - - case MPV_EVENT_COMMAND_REPLY: - // Handle async command completion - let requestId = event.reply_userdata - pendingCommandsLock.lock() - let completion = pendingCommands.removeValue(forKey: requestId) - pendingCommandsLock.unlock() - - if let completion = completion { - if event.error < 0 { - let error = NSError(domain: "mpv", code: Int(event.error), - userInfo: [NSLocalizedDescriptionKey: String(cString: mpv_error_string(event.error))]) - DispatchQueue.main.async { completion(.failure(error)) } - } else { - DispatchQueue.main.async { completion(.success(())) } - } - } - - case MPV_EVENT_FILE_LOADED: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "file-loaded", data: nil) - } - - case MPV_EVENT_END_FILE: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "end-file", data: nil) - } - - case MPV_EVENT_SHUTDOWN: - print("[MpvPlayerCore] MPV shutdown event") - - case MPV_EVENT_PLAYBACK_RESTART: - DispatchQueue.main.async { - self.delegate?.onEvent(name: "playback-restart", data: nil) - } - - case MPV_EVENT_LOG_MESSAGE: - if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { - let msg = msgPtr.pointee - let prefix = msg.prefix.map { safeString($0) } ?? "" - let level = msg.level.map { safeString($0) } ?? "" - let text = msg.text.map { safeString($0) } ?? "" - - DispatchQueue.main.async { - self.delegate?.onEvent(name: "log-message", data: [ - "prefix": prefix, - "level": level, - "text": text - ]) - } - } - - default: - break - } - } - - private func handlePropertyChange(name: String, property: mpv_event_property) { - var value: Any? - - switch property.format { - case MPV_FORMAT_DOUBLE: - if let ptr = property.data { - value = ptr.assumingMemoryBound(to: Double.self).pointee - } - - case MPV_FORMAT_FLAG: - if let ptr = property.data { - value = ptr.assumingMemoryBound(to: Int32.self).pointee != 0 - } - - case MPV_FORMAT_NODE: - if let ptr = property.data { - let node = ptr.assumingMemoryBound(to: mpv_node.self).pointee - value = convertNode(node) - } - - case MPV_FORMAT_STRING: - if let ptr = property.data { - let cstr = ptr.assumingMemoryBound(to: UnsafePointer?.self).pointee - value = cstr.map { safeString($0) } - } - - default: - 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 + let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0 + metalLayer.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: - return node.u.string.map { safeString($0) } + func dispose() { + endPlaybackActivity() + NotificationCenter.default.removeObserver(self) + disposeSharedState(destroySynchronously: true) - case MPV_FORMAT_FLAG: - return node.u.flag != 0 - - case MPV_FORMAT_INT64: - return node.u.int64 - - case MPV_FORMAT_DOUBLE: - return node.u.double_ - - case MPV_FORMAT_NODE_ARRAY: - guard let list = node.u.list?.pointee else { return nil } - var array = [Any]() - for i in 0..