refactor: share Apple MPV core and media helpers
This commit is contained in:
@@ -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)))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1864,374 +1853,379 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
|||||||
|
|
||||||
final content = OverlaySheetHost(
|
final content = OverlaySheetHost(
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: handleBack,
|
onKeyEvent: handleBack,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
// Hero header with background art
|
// Hero header with background art
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Background Art (fixed height, no parallax)
|
// Background Art (fixed height, no parallax)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: headerHeight,
|
height: headerHeight,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: (metadata.art != null || metadata.backgroundSquare != null)
|
child: (metadata.art != null || metadata.backgroundSquare != null)
|
||||||
? Builder(
|
? Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final containerAspect = size.width / headerHeight;
|
final containerAspect = size.width / headerHeight;
|
||||||
final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect);
|
final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect);
|
||||||
|
|
||||||
// Check for offline local file first
|
// Check for offline local file first
|
||||||
if (widget.isOffline && widget.metadata.serverId != null) {
|
if (widget.isOffline && widget.metadata.serverId != null) {
|
||||||
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
|
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
|
||||||
widget.metadata.serverId!,
|
widget.metadata.serverId!,
|
||||||
heroArtPath,
|
heroArtPath,
|
||||||
);
|
|
||||||
if (localPath != null && File(localPath).existsSync()) {
|
|
||||||
return Image.file(
|
|
||||||
File(localPath),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(),
|
|
||||||
);
|
);
|
||||||
|
if (localPath != null && File(localPath).existsSync()) {
|
||||||
|
return Image.file(
|
||||||
|
File(localPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Offline but no local file - show placeholder
|
||||||
|
return const PlaceholderContainer();
|
||||||
}
|
}
|
||||||
// Offline but no local file - show placeholder
|
|
||||||
return const PlaceholderContainer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Online - use network image
|
// Online - use network image
|
||||||
final client = _getClientForMetadata(context);
|
final client = _getClientForMetadata(context);
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
||||||
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
|
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||||
client: client,
|
client: client,
|
||||||
thumbPath: heroArtPath,
|
thumbPath: heroArtPath,
|
||||||
maxWidth: mediaQuery.size.width,
|
maxWidth: mediaQuery.size.width,
|
||||||
maxHeight: mediaQuery.size.height * 0.6,
|
maxHeight: mediaQuery.size.height * 0.6,
|
||||||
devicePixelRatio: dpr,
|
devicePixelRatio: dpr,
|
||||||
imageType: ImageType.art,
|
imageType: ImageType.art,
|
||||||
);
|
);
|
||||||
|
|
||||||
return blurArtwork(CachedNetworkImage(
|
return blurArtwork(
|
||||||
imageUrl: imageUrl,
|
CachedNetworkImage(
|
||||||
fit: BoxFit.cover,
|
imageUrl: imageUrl,
|
||||||
placeholder: (context, url) => const PlaceholderContainer(),
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
placeholder: (context, url) => const PlaceholderContainer(),
|
||||||
));
|
errorWidget: (context, url, error) => const PlaceholderContainer(),
|
||||||
},
|
),
|
||||||
)
|
);
|
||||||
: const PlaceholderContainer(),
|
},
|
||||||
),
|
)
|
||||||
|
: const PlaceholderContainer(),
|
||||||
// Gradient overlay
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: -1, // Extend 1px past to prevent subpixel gap
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
|
|
||||||
stops: const [0.3, 0.8, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Content at bottom
|
// Gradient overlay
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 16,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: SafeArea(
|
bottom: -1, // Extend 1px past to prevent subpixel gap
|
||||||
child: Padding(
|
child: Builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
builder: (context) {
|
||||||
child: Column(
|
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: BoxDecoration(
|
||||||
children: [
|
gradient: LinearGradient(
|
||||||
// Clear logo or title
|
begin: Alignment.topCenter,
|
||||||
if (metadata.clearLogo != null)
|
end: Alignment.bottomCenter,
|
||||||
SizedBox(
|
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
|
||||||
height: 120,
|
stops: const [0.3, 0.8, 1.0],
|
||||||
width: 400,
|
),
|
||||||
child: Builder(
|
),
|
||||||
builder: (context) {
|
);
|
||||||
// Check for offline local file first
|
},
|
||||||
if (widget.isOffline && widget.metadata.serverId != null) {
|
),
|
||||||
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
|
),
|
||||||
widget.metadata.serverId!,
|
|
||||||
metadata.clearLogo,
|
// Content at bottom
|
||||||
|
Positioned(
|
||||||
|
bottom: 16,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Clear logo or title
|
||||||
|
if (metadata.clearLogo != null)
|
||||||
|
SizedBox(
|
||||||
|
height: 120,
|
||||||
|
width: 400,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Check for offline local file first
|
||||||
|
if (widget.isOffline && widget.metadata.serverId != null) {
|
||||||
|
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
|
||||||
|
widget.metadata.serverId!,
|
||||||
|
metadata.clearLogo,
|
||||||
|
);
|
||||||
|
if (localPath != null && File(localPath).existsSync()) {
|
||||||
|
return Image.file(
|
||||||
|
File(localPath),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
_buildTitleText(context, metadata.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Offline but no local file - show title text
|
||||||
|
return _buildTitleText(context, metadata.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online - use network image
|
||||||
|
final client = _getClientForMetadata(context);
|
||||||
|
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
||||||
|
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||||
|
client: client,
|
||||||
|
thumbPath: metadata.clearLogo,
|
||||||
|
maxWidth: 400,
|
||||||
|
maxHeight: 120,
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
imageType: ImageType.logo,
|
||||||
);
|
);
|
||||||
if (localPath != null && File(localPath).existsSync()) {
|
|
||||||
return Image.file(
|
return blurArtwork(
|
||||||
File(localPath),
|
CachedNetworkImage(
|
||||||
|
imageUrl: logoUrl,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
errorBuilder: (context, error, stackTrace) =>
|
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||||
_buildTitleText(context, metadata.title),
|
placeholder: (context, url) => Align(
|
||||||
);
|
alignment: Alignment.centerLeft,
|
||||||
}
|
child: Text(
|
||||||
// Offline but no local file - show title text
|
metadata.title,
|
||||||
return _buildTitleText(context, metadata.title);
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
}
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
// Online - use network image
|
shadows: [
|
||||||
final client = _getClientForMetadata(context);
|
Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8),
|
||||||
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
|
],
|
||||||
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
),
|
||||||
client: client,
|
maxLines: 2,
|
||||||
thumbPath: metadata.clearLogo,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxWidth: 400,
|
),
|
||||||
maxHeight: 120,
|
|
||||||
devicePixelRatio: dpr,
|
|
||||||
imageType: ImageType.logo,
|
|
||||||
);
|
|
||||||
|
|
||||||
return blurArtwork(CachedNetworkImage(
|
|
||||||
imageUrl: logoUrl,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
|
||||||
placeholder: (context, url) => Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
metadata.title,
|
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
shadows: [
|
|
||||||
Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
errorWidget: (context, url, error) {
|
||||||
overflow: TextOverflow.ellipsis,
|
return _buildTitleText(context, metadata.title);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
sigma: 10,
|
||||||
errorWidget: (context, url, error) {
|
clip: false,
|
||||||
return _buildTitleText(context, metadata.title);
|
);
|
||||||
},
|
},
|
||||||
), sigma: 10, clip: false);
|
),
|
||||||
},
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
metadata.title,
|
||||||
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
)
|
const SizedBox(height: 12),
|
||||||
else
|
|
||||||
Text(
|
|
||||||
metadata.title,
|
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Metadata chips
|
// Metadata chips
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
if (metadata.year != null) _buildMetadataChip('${metadata.year}'),
|
if (metadata.year != null) _buildMetadataChip('${metadata.year}'),
|
||||||
if (metadata.contentRating != null)
|
if (metadata.contentRating != null)
|
||||||
_buildMetadataChip(formatContentRating(metadata.contentRating!)),
|
_buildMetadataChip(formatContentRating(metadata.contentRating!)),
|
||||||
if (metadata.duration != null)
|
if (metadata.duration != null)
|
||||||
_buildMetadataChip(formatDurationTextual(metadata.duration!)),
|
_buildMetadataChip(formatDurationTextual(metadata.duration!)),
|
||||||
..._buildRatingChips(metadata),
|
..._buildRatingChips(metadata),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Action buttons
|
// Action buttons
|
||||||
_buildActionButtons(metadata),
|
_buildActionButtons(metadata),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Main content
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Summary
|
|
||||||
if (metadata.summary != null) ...[
|
|
||||||
Text(
|
|
||||||
key: _overviewSectionKey,
|
|
||||||
t.discover.overview,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Focus(
|
|
||||||
focusNode: _overviewFocusNode,
|
|
||||||
onKeyEvent: _handleOverviewKeyEvent,
|
|
||||||
onFocusChange: (_) => setState(() {}),
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final showFocus =
|
|
||||||
_overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
border: Border.all(
|
|
||||||
color: showFocus
|
|
||||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)
|
|
||||||
: Colors.transparent,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: () {
|
|
||||||
final summaryStyle =
|
|
||||||
Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6);
|
|
||||||
if (isTv) {
|
|
||||||
return Text(metadata.summary!, style: summaryStyle);
|
|
||||||
}
|
|
||||||
return CollapsibleText(
|
|
||||||
text: metadata.summary!,
|
|
||||||
maxLines: isMobile ? 6 : 4,
|
|
||||||
style: summaryStyle,
|
|
||||||
);
|
|
||||||
}(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Seasons (for TV shows)
|
|
||||||
if (isShow) ...[
|
|
||||||
Text(
|
|
||||||
key: _seasonsSectionKey,
|
|
||||||
t.discover.seasons,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (_isLoadingSeasons)
|
|
||||||
const Center(
|
|
||||||
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (_seasons.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
t.messages.noSeasonsFound,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (size.width >= 600)
|
|
||||||
_buildHorizontalSeasons()
|
|
||||||
else
|
|
||||||
_buildVerticalSeasons(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Cast
|
|
||||||
if (metadata.role != null && metadata.role!.isNotEmpty) ...[
|
|
||||||
Text(
|
|
||||||
key: _castSectionKey,
|
|
||||||
t.discover.cast,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildCastSection(metadata),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Trailers & Extras Section
|
|
||||||
if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[
|
|
||||||
Text(
|
|
||||||
key: _extrasSectionKey,
|
|
||||||
t.discover.extras,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildExtrasSection(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Additional info
|
|
||||||
if (metadata.studio != null) ...[
|
|
||||||
_buildInfoRow(t.discover.studio, metadata.studio!),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
if (metadata.contentRating != null) ...[
|
|
||||||
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)),
|
// Main content
|
||||||
],
|
SliverToBoxAdapter(
|
||||||
),
|
child: Padding(
|
||||||
// Sticky top bar with fading background
|
padding: const EdgeInsets.all(24),
|
||||||
Positioned(
|
child: Column(
|
||||||
top: 0,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
left: 0,
|
children: [
|
||||||
right: 0,
|
// Summary
|
||||||
child: IgnorePointer(
|
if (metadata.summary != null) ...[
|
||||||
ignoring: _scrollOffset < 50,
|
Text(
|
||||||
child: AnimatedOpacity(
|
key: _overviewSectionKey,
|
||||||
opacity: (_scrollOffset / 100).clamp(0.0, 1.0),
|
t.discover.overview,
|
||||||
duration: const Duration(milliseconds: 150),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
child: Container(
|
),
|
||||||
height: MediaQuery.of(context).padding.top + 58,
|
const SizedBox(height: 12),
|
||||||
decoration: BoxDecoration(
|
Focus(
|
||||||
gradient: LinearGradient(
|
focusNode: _overviewFocusNode,
|
||||||
begin: Alignment.topCenter,
|
onKeyEvent: _handleOverviewKeyEvent,
|
||||||
end: Alignment.bottomCenter,
|
onFocusChange: (_) => setState(() {}),
|
||||||
colors: [
|
child: Builder(
|
||||||
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
|
builder: (context) {
|
||||||
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.5),
|
final showFocus =
|
||||||
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0),
|
_overviewFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
border: Border.all(
|
||||||
|
color: showFocus
|
||||||
|
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)
|
||||||
|
: Colors.transparent,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: () {
|
||||||
|
final summaryStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.6);
|
||||||
|
if (isTv) {
|
||||||
|
return Text(metadata.summary!, style: summaryStyle);
|
||||||
|
}
|
||||||
|
return CollapsibleText(
|
||||||
|
text: metadata.summary!,
|
||||||
|
maxLines: isMobile ? 6 : 4,
|
||||||
|
style: summaryStyle,
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Seasons (for TV shows)
|
||||||
|
if (isShow) ...[
|
||||||
|
Text(
|
||||||
|
key: _seasonsSectionKey,
|
||||||
|
t.discover.seasons,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_isLoadingSeasons)
|
||||||
|
const Center(
|
||||||
|
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (_seasons.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
t.messages.noSeasonsFound,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (size.width >= 600)
|
||||||
|
_buildHorizontalSeasons()
|
||||||
|
else
|
||||||
|
_buildVerticalSeasons(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Cast
|
||||||
|
if (metadata.role != null && metadata.role!.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
key: _castSectionKey,
|
||||||
|
t.discover.cast,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildCastSection(metadata),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Trailers & Extras Section
|
||||||
|
if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
key: _extrasSectionKey,
|
||||||
|
t.discover.extras,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildExtrasSection(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Additional info
|
||||||
|
if (metadata.studio != null) ...[
|
||||||
|
_buildInfoRow(t.discover.studio, metadata.studio!),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (metadata.contentRating != null) ...[
|
||||||
|
_buildInfoRow(t.discover.rating, formatContentRating(metadata.contentRating!)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
stops: const [0.0, 0.3, 1.0],
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Sticky top bar with fading background
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _scrollOffset < 50,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: (_scrollOffset / 100).clamp(0.0, 1.0),
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: Container(
|
||||||
|
height: MediaQuery.of(context).padding.top + 58,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
|
||||||
|
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.5),
|
||||||
|
Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0),
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.3, 1.0],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Back button (always visible)
|
||||||
// Back button (always visible)
|
Positioned(
|
||||||
Positioned(
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
child: DesktopAppBarHelper.buildAdjustedLeading(
|
||||||
child: DesktopAppBarHelper.buildAdjustedLeading(
|
AppBarBackButton(
|
||||||
AppBarBackButton(
|
style: BackButtonStyle.circular,
|
||||||
style: BackButtonStyle.circular,
|
onPressed: () => Navigator.pop(context, _watchStateChanged),
|
||||||
onPressed: () => Navigator.pop(context, _watchStateChanged),
|
),
|
||||||
),
|
context: context,
|
||||||
context: context,
|
)!,
|
||||||
)!,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context);
|
final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context);
|
||||||
@@ -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> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user