refactor: share Apple MPV core and media helpers

This commit is contained in:
edde746
2026-03-06 10:16:33 +01:00
parent 72aed76a1c
commit c1e2bc36ea
19 changed files with 1377 additions and 1929 deletions
@@ -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<CChar>) -> 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<Void>()
private var pendingCommands: [UInt64: (Result<Void, Error>) -> 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<MpvPlayerCoreBase>.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, Error>) -> 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<CChar>?] = 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<CChar>?] = ([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<CChar>?.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..<Int(list.num) {
if let item = convertNode(list.values[index]) {
array.append(item)
}
}
return array
case MPV_FORMAT_NODE_MAP:
guard let list = node.u.list?.pointee else { return nil }
var dictionary = [String: Any]()
for index in 0..<Int(list.num) {
if let key = list.keys?[index].map({ safeString($0) }),
let value = convertNode(list.values[index]) {
dictionary[key] = value
}
}
return dictionary
default:
return nil
}
}
func checkError(_ status: CInt) {
if status < 0 {
print("[MpvPlayerCore] MPV error: \(safeString(mpv_error_string(status)))")
}
}
}
+4
View File
@@ -15,6 +15,7 @@
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit */; }; 6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit */; };
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; }; 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; }; 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; }; 92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -58,6 +59,7 @@
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; }; 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; }; 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; }; 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; }; 92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -115,6 +117,7 @@
6A8A46242EDB370C0057B88C /* MpvPlayer */ = { 6A8A46242EDB370C0057B88C /* MpvPlayer */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */, 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */, 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */, 92F969587D0E464D999910F4 /* MpvPipController.swift */,
@@ -416,6 +419,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */, 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */, 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */, 92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
+98 -622
View File
@@ -1,85 +1,13 @@
import Libmpv import Libmpv
import UIKit import UIKit
/// Protocol for receiving player events /// Core MPV player using Metal rendering for iOS.
protocol MpvPlayerDelegate: AnyObject { class MpvPlayerCore: MpvPlayerCoreBase {
func onPropertyChange(name: String, value: Any?)
func onEvent(name: String, data: [String: Any]?)
}
// Workaround for MoltenVK problems that cause flicker private var containerView: UIView?
// https://github.com/mpv-player/mpv/pull/13651
private class MetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { return super.drawableSize }
set {
if Int(newValue.width) > 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<CChar>) -> 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 weak var window: UIWindow? private weak var window: UIWindow?
private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated)
weak var delegate: MpvPlayerDelegate? var isPipStarting = false
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, Error>) -> Void] = [:]
private var pendingCommandsLock = NSLock()
private var nextRequestId: UInt64 = 1
// MARK: - Initialization
func initialize(in window: UIWindow) -> Bool { func initialize(in window: UIWindow) -> Bool {
guard !isInitialized else { guard !isInitialized else {
@@ -89,14 +17,11 @@ class MpvPlayerCore: NSObject {
self.window = window 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) let container = UIView(frame: window.bounds)
container.backgroundColor = .clear container.backgroundColor = .clear
container.isUserInteractionEnabled = false container.isUserInteractionEnabled = false
// Create Metal layer for video rendering let layer = MpvMetalLayer()
let layer = MetalLayer()
layer.frame = container.bounds layer.frame = container.bounds
layer.contentsScale = UIScreen.main.nativeScale layer.contentsScale = UIScreen.main.nativeScale
layer.framebufferOnly = true layer.framebufferOnly = true
@@ -106,10 +31,8 @@ class MpvPlayerCore: NSObject {
containerView = container containerView = container
metalLayer = layer metalLayer = layer
// Add container view to window (behind Flutter's root view controller)
window.insertSubview(container, at: 0) window.insertSubview(container, at: 0)
// Initialize MPV with this Metal layer
guard setupMpv() else { guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV") print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer() layer.removeFromSuperlayer()
@@ -119,7 +42,6 @@ class MpvPlayerCore: NSObject {
return false return false
} }
// Setup background/foreground notifications
setupNotifications() setupNotifications()
isInitialized = true isInitialized = true
@@ -127,60 +49,106 @@ class MpvPlayerCore: NSObject {
return true return true
} }
private func setupMpv() -> Bool { func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
guard let metalLayer = metalLayer else { return false } guard let mpv else { return false }
mpv = mpv_create() print("[MpvPlayerCore] Switching to pip VO for PiP")
guard mpv != nil else {
print("[MpvPlayerCore] Failed to create MPV context")
return false
}
// Logging metalLayer?.removeFromSuperlayer()
#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 &) mpv_set_property_string(mpv, "vid", "no")
var layer = metalLayer
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
// Video output settings for Metal/Vulkan var pointer = Int64(Int(bitPattern: layerPtr))
checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer)
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
// Initialize MPV mpv_set_property_string(mpv, "vo", "pip")
let initResult = mpv_initialize(mpv) mpv_set_property_string(mpv, "vid", "auto")
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<MpvPlayerCore>.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] Switched to pip VO successfully")
return true 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() { private func setupNotifications() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -202,7 +170,7 @@ class MpvPlayerCore: NSObject {
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video") print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
return return
} }
// Disable video output to fix black screen when returning from background
print("[MpvPlayerCore] Entering background - disabling video") print("[MpvPlayerCore] Entering background - disabling video")
if mpv != nil { if mpv != nil {
mpv_set_option_string(mpv, "vid", "no") mpv_set_option_string(mpv, "vid", "no")
@@ -210,506 +178,14 @@ class MpvPlayerCore: NSObject {
} }
@objc private func enterForeground() { @objc private func enterForeground() {
// Skip if PiP is active - video is already enabled
if isPipActive { if isPipActive {
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore") print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
return return
} }
// Re-enable video output
print("[MpvPlayerCore] Entering foreground - enabling video") print("[MpvPlayerCore] Entering foreground - enabling video")
if mpv != nil { if mpv != nil {
mpv_set_option_string(mpv, "vid", "auto") 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, Error>) -> 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<CChar>?] = 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<CChar>?] = ([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<CChar>?.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..<Int(list.num) {
if let item = convertNode(list.values[i]) {
array.append(item)
}
}
return array
case MPV_FORMAT_NODE_MAP:
guard let list = node.u.list?.pointee else { return nil }
var dict = [String: Any]()
for i in 0..<Int(list.num) {
if let key = list.keys?[i].map({ safeString($0) }),
let val = convertNode(list.values[i])
{
dict[key] = val
}
}
return dict
default:
return nil
}
}
private func checkError(_ status: CInt) {
if status < 0 {
print("[MpvPlayerCore] MPV error: \(String(cString: mpv_error_string(status)))")
}
}
// MARK: - Cleanup
func dispose() {
// Set disposing flag first to prevent race conditions with event callbacks
isDisposing = true
NotificationCenter.default.removeObserver(self)
// Cancel any pending async commands
pendingCommandsLock.lock()
let pending = pendingCommands
pendingCommands.removeAll()
pendingCommandsLock.unlock()
// Complete pending commands with cancellation error
let cancelError = NSError(domain: "mpv", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player disposed"])
for (_, completion) in pending {
DispatchQueue.main.async { completion(.failure(cancelError)) }
}
let mpvHandle = mpv
mpv = nil
// Use async to avoid blocking the main thread (prevents deadlock)
queue.async {
if let handle = mpvHandle {
mpv_set_wakeup_callback(handle, nil, nil)
mpv_terminate_destroy(handle)
}
}
metalLayer?.removeFromSuperlayer()
metalLayer = nil
containerView?.removeFromSuperview()
containerView = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
} }
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
/// Provides a safe [setState] wrapper for async callbacks.
mixin MountedSetStateMixin<T extends StatefulWidget> on State<T> {
void setStateIfMounted(VoidCallback fn) {
if (mounted) {
setState(fn);
}
}
}
+21
View File
@@ -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<T extends StatefulWidget> on State<T> {
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);
}
+1
View File
@@ -51,6 +51,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
bool initialized = false; bool initialized = false;
/// Whether the player has been disposed. /// Whether the player has been disposed.
@override
bool get disposed => _disposed; bool get disposed => _disposed;
/// The method channel for platform communication. /// The method channel for platform communication.
+46 -51
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../utils/global_key_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -36,7 +35,6 @@ import '../theme/mono_tokens.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import '../utils/scroll_utils.dart'; import '../utils/scroll_utils.dart';
import '../utils/provider_extensions.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
@@ -48,6 +46,8 @@ import '../widgets/overlay_sheet.dart';
import '../widgets/placeholder_container.dart'; import '../widgets/placeholder_container.dart';
import '../mixins/watch_state_aware.dart'; import '../mixins/watch_state_aware.dart';
import '../mixins/deletion_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/watch_state_notifier.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import 'season_detail_screen.dart'; import 'season_detail_screen.dart';
@@ -62,7 +62,8 @@ class MediaDetailScreen extends StatefulWidget {
State<MediaDetailScreen> createState() => _MediaDetailScreenState(); State<MediaDetailScreen> createState() => _MediaDetailScreenState();
} }
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware, DeletionAware { class _MediaDetailScreenState extends State<MediaDetailScreen>
with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin {
List<PlexMetadata> _seasons = []; List<PlexMetadata> _seasons = [];
bool _isLoadingSeasons = false; bool _isLoadingSeasons = false;
Completer<void>? _seasonsCompleter; Completer<void>? _seasonsCompleter;
@@ -110,13 +111,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final _castSectionKey = GlobalKey(); final _castSectionKey = GlobalKey();
final _seasonsSectionKey = GlobalKey(); final _seasonsSectionKey = GlobalKey();
String _toGlobalKey(String ratingKey, {String? serverId}) => @override
buildGlobalKey(serverId ?? widget.metadata.serverId ?? '', ratingKey); PlexMetadata get serverBoundMetadata => widget.metadata;
/// Calls [setState] only if the widget is still mounted. @override
void _setStateIfMounted(VoidCallback fn) { bool get isServerBoundOffline => widget.isOffline;
if (mounted) setState(fn);
}
// WatchStateAware: watch the show/movie and all season ratingKeys // WatchStateAware: watch the show/movie and all season ratingKeys
@override @override
@@ -129,16 +128,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
} }
@override @override
String? get watchStateServerId => widget.metadata.serverId; String? get watchStateServerId => serverBoundServerId;
@override @override
Set<String>? get watchedGlobalKeys { Set<String>? get watchedGlobalKeys {
final serverId = widget.metadata.serverId; final serverId = serverBoundServerId;
if (serverId == null) return null; if (serverId == null) return null;
final keys = <String>{_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; final keys = <String>{toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)};
for (final season in _seasons) { 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; return keys;
} }
@@ -161,16 +160,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
} }
@override @override
String? get deletionServerId => widget.metadata.serverId; String? get deletionServerId => serverBoundServerId;
@override @override
Set<String>? get deletionGlobalKeys { Set<String>? get deletionGlobalKeys {
final serverId = widget.metadata.serverId; final serverId = serverBoundServerId;
if (serverId == null) return null; if (serverId == null) return null;
final keys = <String>{_toGlobalKey(widget.metadata.ratingKey, serverId: serverId)}; final keys = <String>{toServerBoundGlobalKey(widget.metadata.ratingKey, serverId: serverId)};
for (final season in _seasons) { 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; return keys;
} }
@@ -238,7 +237,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?;
if (metadata != null) { if (metadata != null) {
_setStateIfMounted(() { setStateIfMounted(() {
_fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); _fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName);
_onDeckEpisode = onDeckEpisode?.copyWith( _onDeckEpisode = onDeckEpisode?.copyWith(
serverId: widget.metadata.serverId, serverId: widget.metadata.serverId,
@@ -250,7 +249,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
// Refresh seasons for updated watched counts (also without loader) // Refresh seasons for updated watched counts (also without loader)
if (widget.metadata.isShow) { if (widget.metadata.isShow) {
final seasons = await client.getChildren(widget.metadata.ratingKey); final seasons = await client.getChildren(widget.metadata.ratingKey);
_setStateIfMounted(() { setStateIfMounted(() {
_seasons = seasons _seasons = seasons
.map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
.toList(); .toList();
@@ -655,8 +654,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
try { try {
final count = await downloadProvider.queueDownload(metadata, client); final count = await downloadProvider.queueDownload(metadata, client);
if (context.mounted) { if (context.mounted) {
final message = final message = count > 1
count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; ? t.downloads.episodesQueued(count: count)
: t.downloads.downloadQueued;
showSuccessSnackBar(context, message); showSuccessSnackBar(context, message);
} }
} on CellularDownloadBlockedException { } on CellularDownloadBlockedException {
@@ -862,16 +862,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
AppIcon( AppIcon(
Symbols.star_rounded, Symbols.star_rounded,
fill: hasRating ? 1 : 0, fill: hasRating ? 1 : 0,
color: hasRating color: hasRating ? Colors.amber : Theme.of(context).colorScheme.onSecondaryContainer,
? Colors.amber
: Theme.of(context).colorScheme.onSecondaryContainer,
size: 16, size: 16,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
hasRating hasRating ? formatRating(starValue) : t.mediaMenu.rate,
? formatRating(starValue)
: t.mediaMenu.rate,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSecondaryContainer, color: Theme.of(context).colorScheme.onSecondaryContainer,
fontSize: 13, fontSize: 13,
@@ -939,10 +935,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
/// Get the correct PlexClient for this metadata's server /// Get the correct PlexClient for this metadata's server
/// Returns null in offline mode or if serverId is null /// Returns null in offline mode or if serverId is null
PlexClient? _getClientForMetadata(BuildContext context) { PlexClient? _getClientForMetadata(BuildContext context) {
if (widget.isOffline || widget.metadata.serverId == null) { return getServerBoundClient(context);
return null;
}
return context.getClientForServer(widget.metadata.serverId!);
} }
Future<void> _loadFullMetadata() async { Future<void> _loadFullMetadata() async {
@@ -1057,12 +1050,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final seasonsWithServerId = seasons final seasonsWithServerId = seasons
.map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
.toList(); .toList();
_setStateIfMounted(() { setStateIfMounted(() {
_seasons = seasonsWithServerId; _seasons = seasonsWithServerId;
_isLoadingSeasons = false; _isLoadingSeasons = false;
}); });
} catch (e) { } catch (e) {
_setStateIfMounted(() { setStateIfMounted(() {
_isLoadingSeasons = false; _isLoadingSeasons = false;
}); });
} finally { } finally {
@@ -1139,7 +1132,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
.map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName))
.toList(); .toList();
_setStateIfMounted(() { setStateIfMounted(() {
_extras = extrasWithServerId; _extras = extrasWithServerId;
}); });
} catch (e) { } catch (e) {
@@ -1423,7 +1416,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
); );
} }
/// Handle key events for the extras row (locked focus pattern) /// Handle key events for the extras row (locked focus pattern)
KeyEventResult _handleExtrasKeyEvent(FocusNode _, KeyEvent event) { KeyEventResult _handleExtrasKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
@@ -1611,7 +1603,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey); final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey);
if (nextEpisode != null) { if (nextEpisode != null) {
_setStateIfMounted(() { setStateIfMounted(() {
_onDeckEpisode = nextEpisode; _onDeckEpisode = nextEpisode;
}); });
appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}'); appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}');
@@ -1651,7 +1643,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
} }
// Single setState to minimize rebuilds - scroll position is preserved by controller // Single setState to minimize rebuilds - scroll position is preserved by controller
_setStateIfMounted(() { setStateIfMounted(() {
_fullMetadata = metadataWithServerId; _fullMetadata = metadataWithServerId;
if (updatedSeasons != null) { if (updatedSeasons != null) {
_seasons = updatedSeasons; _seasons = updatedSeasons;
@@ -1690,10 +1682,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
} }
// Skip Season 0 (Specials) — prefer the first regular season // Skip Season 0 (Specials) — prefer the first regular season
final firstSeason = _seasons.firstWhere( final firstSeason = _seasons.firstWhere((s) => (s.index ?? 0) > 0, orElse: () => _seasons.first);
(s) => (s.index ?? 0) > 0,
orElse: () => _seasons.first,
);
// Get episodes of the first season // Get episodes of the first season
List<PlexMetadata> episodes; List<PlexMetadata> episodes;
@@ -1915,12 +1904,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
imageType: ImageType.art, imageType: ImageType.art,
); );
return blurArtwork(CachedNetworkImage( return blurArtwork(
CachedNetworkImage(
imageUrl: imageUrl, imageUrl: imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => const PlaceholderContainer(), placeholder: (context, url) => const PlaceholderContainer(),
errorWidget: (context, url, error) => const PlaceholderContainer(), errorWidget: (context, url, error) => const PlaceholderContainer(),
)); ),
);
}, },
) )
: const PlaceholderContainer(), : const PlaceholderContainer(),
@@ -1999,7 +1990,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
imageType: ImageType.logo, imageType: ImageType.logo,
); );
return blurArtwork(CachedNetworkImage( return blurArtwork(
CachedNetworkImage(
imageUrl: logoUrl, imageUrl: logoUrl,
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
fit: BoxFit.contain, fit: BoxFit.contain,
@@ -2023,7 +2015,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
return _buildTitleText(context, metadata.title); return _buildTitleText(context, metadata.title);
}, },
), sigma: 10, clip: false); ),
sigma: 10,
clip: false,
);
}, },
), ),
) )
@@ -2101,8 +2096,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
), ),
), ),
child: () { child: () {
final summaryStyle = final summaryStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6);
Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6);
if (isTv) { if (isTv) {
return Text(metadata.summary!, style: summaryStyle); return Text(metadata.summary!, style: summaryStyle);
} }
@@ -2331,8 +2325,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
children: [ children: [
Text( Text(
actor.tag, actor.tag,
style: style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -2558,7 +2551,10 @@ class _SeasonCardState extends State<_SeasonCard> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 3),
Text( Text(
(widget.season.userRating! / 2) == (widget.season.userRating! / 2).truncateToDouble() (widget.season.userRating! / 2) == (widget.season.userRating! / 2).truncateToDouble()
@@ -2656,4 +2652,3 @@ class _SeasonCardState extends State<_SeasonCard> {
); );
} }
} }
+20 -28
View File
@@ -9,7 +9,6 @@ import 'package:provider/provider.dart';
import '../../services/plex_client.dart'; import '../../services/plex_client.dart';
import '../main.dart'; import '../main.dart';
import '../focus/focusable_wrapper.dart'; import '../focus/focusable_wrapper.dart';
import '../utils/global_key_utils.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
@@ -21,7 +20,6 @@ import '../services/download_storage_service.dart';
import '../widgets/collapsible_text.dart'; import '../widgets/collapsible_text.dart';
import '../widgets/plex_optimized_image.dart'; import '../widgets/plex_optimized_image.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../utils/provider_extensions.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
@@ -31,6 +29,8 @@ import '../widgets/placeholder_container.dart';
import '../mixins/item_updatable.dart'; import '../mixins/item_updatable.dart';
import '../mixins/watch_state_aware.dart'; import '../mixins/watch_state_aware.dart';
import '../mixins/deletion_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/watch_state_notifier.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import '../theme/mono_tokens.dart'; import '../theme/mono_tokens.dart';
@@ -47,7 +47,7 @@ class SeasonDetailScreen extends StatefulWidget {
} }
class _SeasonDetailScreenState extends State<SeasonDetailScreen> class _SeasonDetailScreenState extends State<SeasonDetailScreen>
with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware { with ItemUpdatable, WatchStateAware, DeletionAware, RouteAware, MountedSetStateMixin, ServerBoundMediaMixin {
PlexClient? _client; PlexClient? _client;
@override @override
@@ -61,27 +61,25 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
bool _suppressNextBackKeyUp = false; bool _suppressNextBackKeyUp = false;
bool _routeSubscribed = false; bool _routeSubscribed = false;
String _toGlobalKey(String ratingKey, {String? serverId}) => @override
buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey); PlexMetadata get serverBoundMetadata => widget.season;
/// Calls [setState] only if the widget is still mounted. @override
void _setStateIfMounted(VoidCallback fn) { bool get isServerBoundOffline => widget.isOffline;
if (mounted) setState(fn);
}
// WatchStateAware: watch all episode ratingKeys // WatchStateAware: watch all episode ratingKeys
@override @override
Set<String>? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet(); Set<String>? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet();
@override @override
String? get watchStateServerId => widget.season.serverId; String? get watchStateServerId => serverBoundServerId;
@override @override
Set<String>? get watchedGlobalKeys { Set<String>? get watchedGlobalKeys {
final serverId = widget.season.serverId; final serverId = serverBoundServerId;
if (serverId == null) return null; 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 @override
@@ -100,15 +98,15 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
} }
@override @override
String? get deletionServerId => widget.season.serverId; String? get deletionServerId => serverBoundServerId;
@override @override
Set<String>? get deletionGlobalKeys { Set<String>? get deletionGlobalKeys {
final serverId = widget.season.serverId; final serverId = serverBoundServerId;
if (serverId == null) return null; if (serverId == null) return null;
final keys = _episodes.map((e) => _toGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet(); final keys = _episodes.map((e) => toServerBoundGlobalKey(e.ratingKey, serverId: e.serverId ?? serverId)).toSet();
keys.add(_toGlobalKey(widget.season.ratingKey, serverId: serverId)); keys.add(toServerBoundGlobalKey(widget.season.ratingKey, serverId: serverId));
return keys; return keys;
} }
@@ -130,14 +128,6 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
} }
} }
/// 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -145,7 +135,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Capture keyboard mode once to avoid rebuild dependency when mode changes // Capture keyboard mode once to avoid rebuild dependency when mode changes
_initialKeyboardMode = InputModeTracker.isKeyboardMode(context); _initialKeyboardMode = InputModeTracker.isKeyboardMode(context);
_client = _getClientForSeason(context); _client = getServerBoundClient(context);
_loadEpisodes(); _loadEpisodes();
}); });
} }
@@ -165,12 +155,12 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
// Episodes are automatically tagged with server info by PlexClient // Episodes are automatically tagged with server info by PlexClient
final episodes = await _client!.getChildren(widget.season.ratingKey); final episodes = await _client!.getChildren(widget.season.ratingKey);
_setStateIfMounted(() { setStateIfMounted(() {
_episodes = episodes; _episodes = episodes;
_isLoadingEpisodes = false; _isLoadingEpisodes = false;
}); });
} catch (e) { } catch (e) {
_setStateIfMounted(() { setStateIfMounted(() {
_isLoadingEpisodes = false; _isLoadingEpisodes = false;
}); });
} }
@@ -583,7 +573,9 @@ class _EpisodeCardState extends State<_EpisodeCard> {
CircularProgressIndicator( CircularProgressIndicator(
value: progress?.progressPercent, value: progress?.progressPercent,
strokeWidth: 1.5, strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(getMutedColor(Theme.of(context).colorScheme.primary)), valueColor: AlwaysStoppedAnimation<Color>(
getMutedColor(Theme.of(context).colorScheme.primary),
),
), ),
], ],
), ),
+17 -20
View File
@@ -86,8 +86,11 @@ List<PlexMetadata> _processOnDeckResponse(Map<String, dynamic> decoded, String s
if (container == null || container['Metadata'] == null) return []; if (container == null || container['Metadata'] == null) return [];
final allItems = (container['Metadata'] as List) final allItems = (container['Metadata'] as List)
.map((json) => PlexMetadata.fromJsonWithImages(json as Map<String, dynamic>) .map(
.copyWith(serverId: serverId, serverName: serverName)) (json) => PlexMetadata.fromJsonWithImages(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList(); .toList();
return allItems.where((item) => !item.isMusicContent).toList(); return allItems.where((item) => !item.isMusicContent).toList();
@@ -143,7 +146,11 @@ class PlexClient {
/// Custom response decoder that handles malformed UTF-8 gracefully. /// Custom response decoder that handles malformed UTF-8 gracefully.
/// Large responses are decoded in a background isolate to avoid ANR. /// Large responses are decoded in a background isolate to avoid ANR.
static FutureOr<String> _lenientUtf8Decoder(List<int> responseBytes, RequestOptions _, ResponseBody _a) { static FutureOr<String> _lenientUtf8Decoder(
List<int> responseBytes,
RequestOptions requestOptions,
ResponseBody responseBody,
) {
if (responseBytes.length > 50 * 1024) { if (responseBytes.length > 50 * 1024) {
return compute(_decodeUtf8, responseBytes); return compute(_decodeUtf8, responseBytes);
} }
@@ -1196,11 +1203,10 @@ class PlexClient {
/// Pass -1 to clear an existing rating /// Pass -1 to clear an existing rating
Future<bool> rateItem(String ratingKey, double rating) { Future<bool> rateItem(String ratingKey, double rating) {
return _wrapBoolApiCall( return _wrapBoolApiCall(
() => _dio.put('/:/rate', queryParameters: { () => _dio.put(
'key': ratingKey, '/:/rate',
'identifier': 'com.plexapp.plugins.library', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', 'rating': rating},
'rating': rating, ),
}),
'Failed to rate item', 'Failed to rate item',
); );
} }
@@ -1335,10 +1341,7 @@ class PlexClient {
/// This matches the official Plex client's home page layout. /// This matches the official Plex client's home page layout.
Future<List<PlexHub>> getGlobalHubs({int limit = 10}) async { Future<List<PlexHub>> getGlobalHubs({int limit = 10}) async {
try { try {
final response = await _dio.get( final response = await _dio.get('/hubs', queryParameters: {'count': limit, 'includeGuids': 1});
'/hubs',
queryParameters: {'count': limit, 'includeGuids': 1},
);
final sid = serverId; final sid = serverId;
final sname = serverName; final sname = serverName;
return Isolate.run(() => _processHubResponse(response.data as Map<String, dynamic>, sid, sname)); return Isolate.run(() => _processHubResponse(response.data as Map<String, dynamic>, sid, sname));
@@ -1544,10 +1547,7 @@ class PlexClient {
String? tagline, String? tagline,
String? summary, String? summary,
}) { }) {
final queryParams = <String, dynamic>{ final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
'type': typeNumber,
'id': ratingKey,
};
void addField(String name, String? value) { void addField(String name, String? value) {
if (value != null) { if (value != null) {
@@ -1602,10 +1602,7 @@ class PlexClient {
() => _dio.put( () => _dio.put(
'/library/metadata/$ratingKey/$setElement', '/library/metadata/$ratingKey/$setElement',
data: bytes, data: bytes,
options: Options( options: Options(headers: {'Content-Length': bytes.length}, contentType: 'application/octet-stream'),
headers: {'Content-Length': bytes.length},
contentType: 'application/octet-stream',
),
), ),
'Failed to upload artwork', 'Failed to upload artwork',
); );
+8 -1
View File
@@ -37,6 +37,13 @@ extension ProviderExtensions on BuildContext {
return serverClient; 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<MultiServerProvider>(this, listen: false);
return multiServerProvider.getClientForServer(serverId);
}
/// Get PlexClient for a library /// Get PlexClient for a library
/// Throws an exception if no client is available /// Throws an exception if no client is available
PlexClient getClientForLibrary(PlexLibrary library) { PlexClient getClientForLibrary(PlexLibrary library) {
@@ -66,7 +73,7 @@ extension ProviderExtensions on BuildContext {
if (isOffline || metadata.serverId == null) { if (isOffline || metadata.serverId == null) {
return null; return null;
} }
return getClientForServer(metadata.serverId!); return tryGetClientForServer(metadata.serverId);
} }
/// Get the first available client from connected servers /// Get the first available client from connected servers
@@ -8,14 +8,13 @@ import 'package:flutter/services.dart';
import '../../focus/dpad_navigator.dart'; import '../../focus/dpad_navigator.dart';
import '../../mpv/mpv.dart'; import '../../mpv/mpv.dart';
import '../../models/plex_media_info.dart'; import '../../models/plex_media_info.dart';
import '../../models/plex_media_version.dart';
import '../../models/plex_metadata.dart'; import '../../models/plex_metadata.dart';
import '../../services/fullscreen_state_manager.dart'; import '../../services/fullscreen_state_manager.dart';
import '../../utils/desktop_window_padding.dart'; import '../../utils/desktop_window_padding.dart';
import '../../utils/formatters.dart'; import '../../utils/formatters.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.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/first_frame_guard.dart';
import 'widgets/play_pause_stream_builder.dart'; import 'widgets/play_pause_stream_builder.dart';
import 'widgets/video_controls_header.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) /// Called when user navigates up from timeline (to hide controls)
final VoidCallback? onHideControls; final VoidCallback? onHideControls;
// Track chapter controls parameters final TrackControlsState trackControlsState;
final List<PlexMediaVersion> 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 VoidCallback? onBack; 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). /// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame; final ValueNotifier<bool>? hasFirstFrame;
final ShaderService? shaderService;
final VoidCallback? onShaderChanged;
/// Optional callback that returns thumbnail image bytes for a given timestamp. /// Optional callback that returns thumbnail image bytes for a given timestamp.
final Uint8List? Function(Duration time)? thumbnailDataBuilder; final Uint8List? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream
final bool isLive;
/// Channel name for live TV display /// Channel name for live TV display
final String? liveChannelName; 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({ const DesktopVideoControls({
super.key, super.key,
required this.player, required this.player,
@@ -126,39 +81,11 @@ class DesktopVideoControls extends StatefulWidget {
this.onFocusActivity, this.onFocusActivity,
this.onRequestPlayPauseFocus, this.onRequestPlayPauseFocus,
this.onHideControls, this.onHideControls,
this.availableVersions = const [], this.trackControlsState = const TrackControlsState(),
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.onBack, this.onBack,
this.canControl = true,
this.hasFirstFrame, this.hasFirstFrame,
this.shaderService,
this.onShaderChanged,
this.thumbnailDataBuilder, this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName, this.liveChannelName,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
this.subtitlesVisible = true,
this.showQueueButton = false,
this.onQueueItemSelected,
}); });
@override @override
@@ -166,6 +93,10 @@ class DesktopVideoControls extends StatefulWidget {
} }
class DesktopVideoControlsState extends State<DesktopVideoControls> { class DesktopVideoControlsState extends State<DesktopVideoControls> {
TrackControlsState get _trackControlsState => widget.trackControlsState;
bool get _canControl => _trackControlsState.canControl;
bool get _isLive => _trackControlsState.isLive;
// Focus nodes for playback control buttons // Focus nodes for playback control buttons
late final FocusNode _prevItemFocusNode; late final FocusNode _prevItemFocusNode;
late final FocusNode _prevChapterFocusNode; late final FocusNode _prevChapterFocusNode;
@@ -370,7 +301,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// LEFT/RIGHT for smooth scrubbing with progressive acceleration // LEFT/RIGHT for smooth scrubbing with progressive acceleration
if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) {
// Ignore seeking if user cannot control // Ignore seeking if user cannot control
if (!widget.canControl) return KeyEventResult.handled; if (!_canControl) return KeyEventResult.handled;
if (duration.inMilliseconds <= 0) return KeyEventResult.handled; if (duration.inMilliseconds <= 0) return KeyEventResult.handled;
@@ -455,7 +386,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onBack: widget.onBack, onBack: widget.onBack,
), ),
), ),
if (widget.isLive) ...[ if (_isLive) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -474,13 +405,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
} }
Widget _buildBottomControlsContent(BuildContext _, {required bool hasFrame}) { Widget _buildBottomControlsContent(BuildContext _, {required bool hasFrame}) {
final canInteract = widget.canControl && hasFrame; final canInteract = _canControl && hasFrame;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column( child: Column(
children: [ children: [
// Row 1: Timeline with time indicators (hidden for live TV) // Row 1: Timeline with time indicators (hidden for live TV)
if (!widget.isLive) ...[ if (!_isLive) ...[
VideoTimelineBar( VideoTimelineBar(
player: widget.player, player: widget.player,
chapters: widget.chapters, chapters: widget.chapters,
@@ -499,16 +430,16 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
// Row 2: Playback controls and options // Row 2: Playback controls and options
Row( Row(
children: [ children: [
if (!widget.isLive) ...[ if (!_isLive) ...[
// Previous item // Previous item
Opacity( Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _prevItemFocusNode, focusNode: _prevItemFocusNode,
index: 0, index: 0,
icon: Symbols.skip_previous_rounded, icon: Symbols.skip_previous_rounded,
color: widget.onPrevious != null && widget.canControl ? Colors.white : Colors.white54, color: widget.onPrevious != null && _canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl ? widget.onPrevious : null, onPressed: _canControl ? widget.onPrevious : null,
semanticLabel: t.videoControls.previousButton, semanticLabel: t.videoControls.previousButton,
), ),
), ),
@@ -519,15 +450,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
builder: (context, posSnapshot) { builder: (context, posSnapshot) {
final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero); final prevLabel = _getPreviousChapterLabel(posSnapshot.data ?? Duration.zero);
return Opacity( return Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _prevChapterFocusNode, focusNode: _prevChapterFocusNode,
index: 1, index: 1,
icon: Symbols.fast_rewind_rounded, icon: Symbols.fast_rewind_rounded,
color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl && widget.chapters.isNotEmpty onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToPreviousChapter : null,
? widget.onSeekToPreviousChapter
: null,
semanticLabel: t.videoControls.previousChapterButton, semanticLabel: t.videoControls.previousChapterButton,
tooltip: prevLabel, tooltip: prevLabel,
), ),
@@ -536,19 +465,19 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
), ),
// Skip backward // Skip backward
Opacity( Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _skipBackFocusNode, focusNode: _skipBackFocusNode,
index: 2, index: 2,
icon: widget.getReplayIcon(widget.seekTimeSmall), icon: widget.getReplayIcon(widget.seekTimeSmall),
onPressed: widget.canControl ? widget.onSeekBackward : null, onPressed: _canControl ? widget.onSeekBackward : null,
semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall), semanticLabel: t.videoControls.seekBackwardButton(seconds: widget.seekTimeSmall),
), ),
), ),
], ],
// Play/Pause // Play/Pause
Opacity( Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: PlayPauseStreamBuilder( child: PlayPauseStreamBuilder(
player: widget.player, player: widget.player,
builder: (context, isPlaying) { builder: (context, isPlaying) {
@@ -557,7 +486,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
index: 3, index: 3,
icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded,
iconSize: 32, iconSize: 32,
onPressed: widget.canControl onPressed: _canControl
? () { ? () {
if (isPlaying) { if (isPlaying) {
widget.player.pause(); widget.player.pause();
@@ -571,15 +500,15 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
}, },
), ),
), ),
if (!widget.isLive) ...[ if (!_isLive) ...[
// Skip forward // Skip forward
Opacity( Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _skipForwardFocusNode, focusNode: _skipForwardFocusNode,
index: 4, index: 4,
icon: widget.getForwardIcon(widget.seekTimeSmall), icon: widget.getForwardIcon(widget.seekTimeSmall),
onPressed: widget.canControl ? widget.onSeekForward : null, onPressed: _canControl ? widget.onSeekForward : null,
semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall), semanticLabel: t.videoControls.seekForwardButton(seconds: widget.seekTimeSmall),
), ),
), ),
@@ -590,13 +519,13 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
builder: (context, posSnapshot) { builder: (context, posSnapshot) {
final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero); final nextLabel = _getNextChapterLabel(posSnapshot.data ?? Duration.zero);
return Opacity( return Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _nextChapterFocusNode, focusNode: _nextChapterFocusNode,
index: 5, index: 5,
icon: Symbols.fast_forward_rounded, icon: Symbols.fast_forward_rounded,
color: widget.chapters.isNotEmpty && widget.canControl ? Colors.white : Colors.white54, color: widget.chapters.isNotEmpty && _canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null, onPressed: _canControl && widget.chapters.isNotEmpty ? widget.onSeekToNextChapter : null,
semanticLabel: t.videoControls.nextChapterButton, semanticLabel: t.videoControls.nextChapterButton,
tooltip: nextLabel, tooltip: nextLabel,
), ),
@@ -605,19 +534,19 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
), ),
// Next item // Next item
Opacity( Opacity(
opacity: widget.canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
focusNode: _nextItemFocusNode, focusNode: _nextItemFocusNode,
index: 6, index: 6,
icon: Symbols.skip_next_rounded, icon: Symbols.skip_next_rounded,
color: widget.onNext != null && widget.canControl ? Colors.white : Colors.white54, color: widget.onNext != null && _canControl ? Colors.white : Colors.white54,
onPressed: widget.canControl ? widget.onNext : null, onPressed: _canControl ? widget.onNext : null,
semanticLabel: t.videoControls.nextButton, semanticLabel: t.videoControls.nextButton,
), ),
), ),
], ],
// Finish time (hidden for live TV and when too narrow to fit) // Finish time (hidden for live TV and when too narrow to fit)
if (widget.isLive) if (_isLive)
const Spacer() const Spacer()
else else
Expanded( Expanded(
@@ -684,39 +613,10 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
player: widget.player, player: widget.player,
chapters: widget.chapters, chapters: widget.chapters,
chaptersLoaded: widget.chaptersLoaded, chaptersLoaded: widget.chaptersLoaded,
availableVersions: widget.availableVersions, trackControlsState: _trackControlsState,
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,
focusNodes: _trackControlFocusNodes, focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange, onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume, 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,
), ),
], ],
), ),
@@ -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<PlexMediaVersion> 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,
});
}
@@ -37,12 +37,7 @@ class ChapterSheet extends StatefulWidget {
class _ChapterSheetState extends State<ChapterSheet> { class _ChapterSheetState extends State<ChapterSheet> {
/// Get the PlexClient for chapters, or null if unavailable (offline mode) /// Get the PlexClient for chapters, or null if unavailable (offline mode)
PlexClient? _tryGetClientForChapters(BuildContext context) { PlexClient? _tryGetClientForChapters(BuildContext context) {
if (widget.serverId == null) return null; return context.tryGetClientForServer(widget.serverId);
try {
return context.getClientForServer(widget.serverId!);
} catch (_) {
return null;
}
} }
@override @override
@@ -113,7 +108,9 @@ class _ChapterSheetState extends State<ChapterSheet> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)), 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<ChapterSheet> {
subtitle: Text( subtitle: Text(
formatDurationTimestamp(chapter.startTime), formatDurationTimestamp(chapter.startTime),
style: TextStyle( 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, fontSize: 12,
), ),
), ),
@@ -132,11 +132,6 @@ class QueueSheet extends StatelessWidget {
} }
static dynamic _tryGetClient(BuildContext context, PlexMetadata item) { static dynamic _tryGetClient(BuildContext context, PlexMetadata item) {
if (item.serverId == null) return null; return context.tryGetClientForServer(item.serverId);
try {
return context.getClientForServer(item.serverId!);
} catch (_) {
return null;
}
} }
} }
+34 -58
View File
@@ -46,6 +46,7 @@ import 'icons.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../focus/input_mode_tracker.dart'; import '../../focus/input_mode_tracker.dart';
import 'models/track_controls_state.dart';
import 'widgets/track_chapter_controls.dart'; import 'widgets/track_chapter_controls.dart';
import 'widgets/performance_overlay/performance_overlay.dart'; import 'widgets/performance_overlay/performance_overlay.dart';
import 'mobile_video_controls.dart'; import 'mobile_video_controls.dart';
@@ -991,13 +992,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers); return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers);
} }
Widget _buildTrackChapterControlsWidget({bool hideChaptersAndQueue = false}) { TrackControlsState _buildTrackControlsState({
final playbackState = context.watch<PlaybackStateProvider>(); required PlaybackStateProvider playbackState,
required VoidCallback? onToggleAlwaysOnTop,
return TrackChapterControls( }) {
player: widget.player, return TrackControlsState(
chapters: _chapters,
chaptersLoaded: _chaptersLoaded,
availableVersions: widget.availableVersions, availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex, selectedMediaIndex: widget.selectedMediaIndex,
boxFitMode: widget.boxFitMode, boxFitMode: widget.boxFitMode,
@@ -1005,17 +1004,18 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
subtitleSyncOffset: _subtitleSyncOffset, subtitleSyncOffset: _subtitleSyncOffset,
isRotationLocked: _isRotationLocked, isRotationLocked: _isRotationLocked,
isFullscreen: _isFullscreen, isFullscreen: _isFullscreen,
isAlwaysOnTop: _isAlwaysOnTop,
onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) onTogglePIPMode: (_isPipSupported && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
? widget.onTogglePIPMode ? widget.onTogglePIPMode
: null, : null,
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null, onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
onToggleRotationLock: _toggleRotationLock, onToggleRotationLock: _toggleRotationLock,
onToggleFullscreen: _toggleFullscreen, onToggleFullscreen: _toggleFullscreen,
onToggleAlwaysOnTop: onToggleAlwaysOnTop,
onSwitchVersion: _switchMediaVersion, onSwitchVersion: _switchMediaVersion,
onAudioTrackChanged: widget.onAudioTrackChanged, onAudioTrackChanged: widget.onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged,
subtitlesVisible: _subtitlesVisible,
onLoadSeekTimes: () async { onLoadSeekTimes: () async {
if (mounted) { if (mounted) {
await _loadSeekTimes(); await _loadSeekTimes();
@@ -1033,15 +1033,31 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}); });
}, },
serverId: widget.metadata.serverId ?? '', serverId: widget.metadata.serverId ?? '',
canControl: widget.canControl,
isLive: widget.isLive,
showQueueButton: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
hideChaptersAndQueue: hideChaptersAndQueue,
shaderService: widget.shaderService, shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged, onShaderChanged: widget.onShaderChanged,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled, isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null, 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<PlaybackStateProvider>();
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<PlexVideoControls> with WindowListen
} }
Widget _buildDesktopControlsListener() { 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<PlaybackStateProvider>(); final playbackState = context.watch<PlaybackStateProvider>();
final trackControlsState = _buildTrackControlsState(
playbackState: playbackState,
onToggleAlwaysOnTop: Platform.isMacOS ? null : _toggleAlwaysOnTop,
);
return Listener( return Listener(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@@ -2058,51 +2074,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
getForwardIcon: getForwardIcon, getForwardIcon: getForwardIcon,
onFocusActivity: _restartHideTimerIfPlaying, onFocusActivity: _restartHideTimerIfPlaying,
onHideControls: _hideControlsFromKeyboard, onHideControls: _hideControlsFromKeyboard,
availableVersions: widget.availableVersions, trackControlsState: trackControlsState,
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 ?? '',
onBack: widget.onBack, onBack: widget.onBack,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame, hasFirstFrame: widget.hasFirstFrame,
showQueueButton: playbackState.isQueueActive,
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
thumbnailDataBuilder: widget.thumbnailDataBuilder, thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName, liveChannelName: widget.liveChannelName,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
onToggleAmbientLighting: widget.player.playerType != 'exoplayer' ? widget.onToggleAmbientLighting : null,
), ),
); );
} }
@@ -65,12 +65,7 @@ class _ContentStripState extends State<ContentStrip> {
} }
PlexClient? _tryGetClient(BuildContext context, String? serverId) { PlexClient? _tryGetClient(BuildContext context, String? serverId) {
if (serverId == null) return null; return context.tryGetClientForServer(serverId);
try {
return context.getClientForServer(serverId);
} catch (_) {
return null;
}
} }
void _autoScrollTo(ScrollController controller, int index, {bool force = false}) { void _autoScrollTo(ScrollController controller, int index, {bool force = false}) {
@@ -93,10 +88,7 @@ class _ContentStripState extends State<ContentStrip> {
children: [ children: [
if (_hasBothTabs) _buildTabBar(), if (_hasBothTabs) _buildTabBar(),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(height: 106, child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip()),
height: 106,
child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip(),
),
], ],
), ),
), ),
@@ -130,11 +122,7 @@ class _ContentStripState extends State<ContentStrip> {
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(height: 2, width: 40, color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent),
height: 2,
width: 40,
color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent,
),
], ],
), ),
); );
@@ -152,7 +140,8 @@ class _ContentStripState extends State<ContentStrip> {
for (int i = 0; i < widget.chapters.length; i++) { for (int i = 0; i < widget.chapters.length; i++) {
final chapter = widget.chapters[i]; final chapter = widget.chapters[i];
final startMs = chapter.startTimeOffset ?? 0; 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()); (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
if (currentPositionMs >= startMs && currentPositionMs < endMs) { if (currentPositionMs >= startMs && currentPositionMs < endMs) {
currentChapterIndex = i; currentChapterIndex = i;
@@ -232,7 +221,7 @@ class _ContentStripState extends State<ContentStrip> {
PlexClient? client; PlexClient? client;
if (item.serverId != null) { if (item.serverId != null) {
try { try {
client = context.getClientForServer(item.serverId!); client = context.tryGetClientForServer(item.serverId);
} catch (_) {} } catch (_) {}
} }
@@ -293,7 +282,8 @@ class _ContentStripState extends State<ContentStrip> {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)), borderRadius: const BorderRadius.all(Radius.circular(6)),
child: thumbnail ?? child:
thumbnail ??
Container( Container(
color: Colors.white10, color: Colors.white10,
child: const Center( child: const Center(
@@ -306,7 +296,9 @@ class _ContentStripState extends State<ContentStrip> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(6)), 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<ContentStrip> {
Text( Text(
subtitle, subtitle,
style: TextStyle( 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, fontSize: 10,
), ),
maxLines: 1, maxLines: 1,
@@ -13,6 +13,7 @@ import '../../../utils/platform_detector.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/overlay_sheet.dart';
import '../../../models/plex_metadata.dart'; import '../../../models/plex_metadata.dart';
import '../models/track_controls_state.dart';
import '../sheets/chapter_sheet.dart'; import '../sheets/chapter_sheet.dart';
import '../sheets/queue_sheet.dart'; import '../sheets/queue_sheet.dart';
import '../sheets/track_sheet.dart'; import '../sheets/track_sheet.dart';
@@ -27,36 +28,7 @@ class TrackChapterControls extends StatelessWidget {
final Player player; final Player player;
final List<PlexChapter> chapters; final List<PlexChapter> chapters;
final bool chaptersLoaded; final bool chaptersLoaded;
final List<PlexMediaVersion> availableVersions; final TrackControlsState trackControlsState;
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;
/// List of FocusNodes for the buttons (passed from parent for navigation) /// List of FocusNodes for the buttons (passed from parent for navigation)
final List<FocusNode>? focusNodes; final List<FocusNode>? focusNodes;
@@ -67,21 +39,6 @@ class TrackChapterControls extends StatelessWidget {
/// Called to navigate left from the first button /// Called to navigate left from the first button
final VoidCallback? onNavigateLeft; 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) /// Whether to hide the chapters and queue buttons (mobile uses content strip instead)
final bool hideChaptersAndQueue; final bool hideChaptersAndQueue;
@@ -90,43 +47,45 @@ class TrackChapterControls extends StatelessWidget {
required this.player, required this.player,
required this.chapters, required this.chapters,
required this.chaptersLoaded, required this.chaptersLoaded,
required this.availableVersions, required this.trackControlsState,
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,
this.focusNodes, this.focusNodes,
this.onFocusChange, this.onFocusChange,
this.onNavigateLeft, this.onNavigateLeft,
this.canControl = true,
this.isLive = false,
this.subtitlesVisible = true,
this.showQueueButton = false,
this.onQueueItemSelected,
this.hideChaptersAndQueue = false, this.hideChaptersAndQueue = false,
this.shaderService,
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
}); });
List<PlexMediaVersion> 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 /// Handle key event for button navigation
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) {
if (!event.isActionable) { if (!event.isActionable) {
+4
View File
@@ -31,6 +31,7 @@
6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; }; 6AC86ED72EA70B4C0067BC66 /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6AC86ED62EA70B4C0067BC66 /* plezy.icon */; };
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; }; 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.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 */; }; 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; };
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.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 = "<group>"; }; 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; }; 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; }; 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
@@ -218,6 +220,7 @@
6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = { 6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */,
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */, 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */, 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */, 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
@@ -469,6 +472,7 @@
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */,
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
+100 -572
View File
@@ -1,87 +1,13 @@
import Cocoa import Cocoa
import Libmpv import Libmpv
/// Protocol for receiving player events /// Core MPV player using Metal rendering.
protocol MpvPlayerDelegate: AnyObject { class MpvPlayerCore: MpvPlayerCoreBase {
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
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<CChar>) -> 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 weak var window: NSWindow?
private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated)
private var playbackActivity: NSObjectProtocol? 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 private var layerHiddenForOcclusion = false
// Async command tracking to prevent UI blocking
private var pendingCommands: [UInt64: (Result<Void, Error>) -> Void] = [:]
private var pendingCommandsLock = NSLock()
private var nextRequestId: UInt64 = 1
// MARK: - Initialization
func initialize(in window: NSWindow) -> Bool { func initialize(in window: NSWindow) -> Bool {
guard !isInitialized else { guard !isInitialized else {
print("[MpvPlayerCore] Already initialized") print("[MpvPlayerCore] Already initialized")
@@ -95,8 +21,7 @@ class MpvPlayerCore: NSObject {
self.window = window self.window = window
// Create Metal layer for video rendering let layer = MpvMetalLayer()
let layer = MetalLayer()
layer.frame = contentView.bounds layer.frame = contentView.bounds
if let screen = window.screen ?? NSScreen.main { if let screen = window.screen ?? NSScreen.main {
layer.contentsScale = screen.backingScaleFactor layer.contentsScale = screen.backingScaleFactor
@@ -108,13 +33,11 @@ class MpvPlayerCore: NSObject {
metalLayer = layer metalLayer = layer
// Ensure contentView has a layer and add our Metal layer
contentView.wantsLayer = true contentView.wantsLayer = true
contentView.layer?.addSublayer(layer) contentView.layer?.addSublayer(layer)
print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)") print("[MpvPlayerCore] Metal layer added, frame: \(layer.frame)")
// Initialize MPV with this Metal layer
guard setupMpv() else { guard setupMpv() else {
print("[MpvPlayerCore] Failed to setup MPV") print("[MpvPlayerCore] Failed to setup MPV")
layer.removeFromSuperlayer() layer.removeFromSuperlayer()
@@ -122,235 +45,54 @@ class MpvPlayerCore: NSObject {
return false return false
} }
// Register for fullscreen notifications to avoid MoltenVK swapchain crash let center = NotificationCenter.default
let nc = NotificationCenter.default center.addObserver(
nc.addObserver(self, selector: #selector(windowWillEnterFullScreen), self,
name: NSWindow.willEnterFullScreenNotification, object: window) selector: #selector(windowWillEnterFullScreen),
nc.addObserver(self, selector: #selector(windowDidEnterFullScreen), name: NSWindow.willEnterFullScreenNotification,
name: NSWindow.didEnterFullScreenNotification, object: window) object: window
nc.addObserver(self, selector: #selector(windowWillExitFullScreen), )
name: NSWindow.willExitFullScreenNotification, object: window) center.addObserver(
nc.addObserver(self, selector: #selector(windowDidExitFullScreen), self,
name: NSWindow.didExitFullScreenNotification, object: window) selector: #selector(windowDidEnterFullScreen),
nc.addObserver(self, selector: #selector(windowOcclusionDidChange), name: NSWindow.didEnterFullScreenNotification,
name: NSWindow.didChangeOcclusionStateNotification, object: window) 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 isInitialized = true
print("[MpvPlayerCore] Initialized successfully with MPV") print("[MpvPlayerCore] Initialized successfully with MPV")
return true return true
} }
// MARK: - Fullscreen Transition Handling override func configurePlatformMpvOptions() {
guard let mpv else { return }
@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"))
checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) 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")) 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<MpvPlayerCore>.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, Error>) -> 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<CChar>?] = 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 } var videoLayer: CAMetalLayer? { metalLayer }
/// Re-attach the Metal layer to the main window after PiP exits.
func reattachMetalLayer() { 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 { if metalLayer.superlayer == nil {
contentView.wantsLayer = true contentView.wantsLayer = true
contentView.layer?.insertSublayer(metalLayer, at: 0) contentView.layer?.insertSublayer(metalLayer, at: 0)
@@ -363,69 +105,40 @@ class MpvPlayerCore: NSObject {
) )
} }
} }
print("[MpvPlayerCore] Metal layer reattached to window") 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() { func forceDraw() {
command(["seek", "0", "relative+exact"]) 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) { func setVisible(_ visible: Bool) {
guard let layer = metalLayer, !isPipActive else { return } guard let metalLayer, !isPipActive else { return }
if visible { if visible {
// Re-insert after background layer but before Flutter control views metalLayer.removeFromSuperlayer()
layer.removeFromSuperlayer()
if let superlayer = window?.contentView?.layer { if let superlayer = window?.contentView?.layer {
superlayer.insertSublayer(layer, at: 0) superlayer.insertSublayer(metalLayer, at: 0)
} }
beginPlaybackActivity() beginPlaybackActivity()
} else { } else {
endPlaybackActivity() endPlaybackActivity()
} }
layer.isHidden = !visible metalLayer.isHidden = !visible
print("[MpvPlayerCore] setVisible(\(visible))") print("[MpvPlayerCore] setVisible(\(visible))")
} }
func updateFrame(_ frame: CGRect? = nil) { 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 metalLayer.frame = frame
} else if let contentView = window?.contentView { } else if let contentView = window?.contentView {
metalLayer.frame = contentView.bounds metalLayer.frame = contentView.bounds
} }
// Update drawable size for proper scaling
if let screen = window?.screen ?? NSScreen.main { if let screen = window?.screen ?? NSScreen.main {
let scale = screen.backingScaleFactor let scale = screen.backingScaleFactor
metalLayer.drawableSize = CGSize( metalLayer.drawableSize = CGSize(
@@ -437,220 +150,75 @@ class MpvPlayerCore: NSObject {
print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") 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<CChar>?] = ([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<CChar>?.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 var edrHeadroom: CGFloat = 1.0
if let screen = window?.screen ?? NSScreen.main { if let screen = window?.screen ?? NSScreen.main {
edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue edrHeadroom = screen.maximumExtendedDynamicRangeColorComponentValue
} }
let isHDRContent = sigPeak > 1.0 let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
let screenSupportsEDR = edrHeadroom > 1.0 metalLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
let shouldEnableEDR = hdrEnabled && isHDRContent && screenSupportsEDR
layer.wantsExtendedDynamicRangeContent = shouldEnableEDR
print( print(
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))" "[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
) )
} }
private func convertNode(_ node: mpv_node) -> Any? { func dispose() {
switch node.format { endPlaybackActivity()
case MPV_FORMAT_STRING: NotificationCenter.default.removeObserver(self)
return node.u.string.map { safeString($0) } disposeSharedState(destroySynchronously: true)
case MPV_FORMAT_FLAG: metalLayer?.removeFromSuperlayer()
return node.u.flag != 0 metalLayer = nil
isInitialized = false
case MPV_FORMAT_INT64: print("[MpvPlayerCore] Disposed")
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..<Int(list.num) {
if let item = convertNode(list.values[i]) {
array.append(item)
}
}
return array
case MPV_FORMAT_NODE_MAP:
guard let list = node.u.list?.pointee else { return nil }
var dict = [String: Any]()
for i in 0..<Int(list.num) {
if let key = list.keys?[i].map({ safeString($0) }),
let val = convertNode(list.values[i]) {
dict[key] = val
}
}
return dict
default:
return nil
}
} }
private func checkError(_ status: CInt) { deinit {
if status < 0 { dispose()
print("[MpvPlayerCore] MPV error: \(String(cString: mpv_error_string(status)))")
}
} }
// MARK: - Power Management @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")
}
@objc private func windowOcclusionDidChange(_ notification: Notification) {
guard let metalLayer, mpv != nil, !isPipActive else { return }
let isVisible = window?.occlusionState.contains(.visible) ?? true
if !isVisible && !layerHiddenForOcclusion {
print("[MpvPlayerCore] Window occluded - hiding Metal layer")
metalLayer.isHidden = true
layerHiddenForOcclusion = true
} else if isVisible && layerHiddenForOcclusion {
print("[MpvPlayerCore] Window visible - showing Metal layer")
layerHiddenForOcclusion = false
metalLayer.isHidden = false
}
}
private func beginPlaybackActivity() { private func beginPlaybackActivity() {
guard playbackActivity == nil else { return } guard playbackActivity == nil else { return }
@@ -662,49 +230,9 @@ class MpvPlayerCore: NSObject {
} }
private func endPlaybackActivity() { private func endPlaybackActivity() {
guard let activity = playbackActivity else { return } guard let playbackActivity else { return }
ProcessInfo.processInfo.endActivity(activity) ProcessInfo.processInfo.endActivity(playbackActivity)
playbackActivity = nil self.playbackActivity = nil
print("[MpvPlayerCore] Ended playback activity assertion") print("[MpvPlayerCore] Ended playback activity assertion")
} }
// MARK: - Cleanup
func dispose() {
endPlaybackActivity()
NotificationCenter.default.removeObserver(self)
// Cancel any pending async commands
pendingCommandsLock.lock()
let pending = pendingCommands
pendingCommands.removeAll()
pendingCommandsLock.unlock()
// Complete pending commands with cancellation error
let cancelError = NSError(domain: "mpv", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Player disposed"])
for (_, completion) in pending {
DispatchQueue.main.async { completion(.failure(cancelError)) }
}
// Capture handle before clearing to avoid weak captures during deinit
let mpvHandle = mpv
mpv = nil
// Tear down on the mpv queue to avoid races with wakeup callbacks still firing
queue.sync {
if let handle = mpvHandle {
mpv_set_wakeup_callback(handle, nil, nil)
mpv_terminate_destroy(handle)
}
}
metalLayer?.removeFromSuperlayer()
metalLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
} }