This commit is contained in:
Alex Kim
2025-04-21 19:54:49 +10:00
parent f477e86718
commit b0c8aefda6

View File

@@ -63,9 +63,8 @@ class MpvPlayerView: ExpoView {
// MARK: - Properties // MARK: - Properties
private var playerController: MpvMetalViewController? private var playerController: MpvMetalViewController?
private var coordinator: MpvMetalPlayerView.Coordinator?
private var hostingController: UIHostingController<MpvMetalPlayerView>?
private var source: [String: Any]? private var source: [String: Any]?
private var externalSubtitles: [[String: String]]?
// MARK: - Event Emitters // MARK: - Event Emitters
@@ -89,36 +88,25 @@ class MpvPlayerView: ExpoView {
private func setupView() { private func setupView() {
backgroundColor = .black backgroundColor = .black
// Create coordinator and configure property change handling print("Setting up direct MPV view")
let coordinator = MpvMetalPlayerView.Coordinator()
coordinator.onPropertyChange = { [weak self] _, propertyName, value in
DispatchQueue.main.async {
self?.handlePropertyChange(propertyName: propertyName, value: value)
}
}
self.coordinator = coordinator
// Create player controller // Create player controller
let controller = MpvMetalViewController() let controller = MpvMetalViewController()
controller.delegate = coordinator
coordinator.player = controller // Configure player delegate
controller.delegate = self
playerController = controller playerController = controller
// Create and add SwiftUI hosting controller // Add the controller's view to our view hierarchy
let hostController = UIHostingController( controller.view.translatesAutoresizingMaskIntoConstraints = false
rootView: MpvMetalPlayerView(coordinator: coordinator, existingController: controller) controller.view.backgroundColor = .clear
)
self.hostingController = hostController
hostController.view.translatesAutoresizingMaskIntoConstraints = false addSubview(controller.view)
hostController.view.backgroundColor = .clear
addSubview(hostController.view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
hostController.view.leadingAnchor.constraint(equalTo: leadingAnchor), controller.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostController.view.trailingAnchor.constraint(equalTo: trailingAnchor), controller.view.trailingAnchor.constraint(equalTo: trailingAnchor),
hostController.view.topAnchor.constraint(equalTo: topAnchor), controller.view.topAnchor.constraint(equalTo: topAnchor),
hostController.view.bottomAnchor.constraint(equalTo: bottomAnchor), controller.view.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
@@ -129,12 +117,19 @@ class MpvPlayerView: ExpoView {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.onVideoLoadStart?(["target": self.reactTag as Any]) self.onVideoLoadStart?(["target": self.reactTag as Any])
// Store external subtitle data
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
if let uri = source["uri"] as? String, let url = URL(string: uri) { if let uri = source["uri"] as? String, let url = URL(string: uri) {
self.coordinator?.playUrl = url print("Loading file: \(url.absoluteString)")
self.coordinator?.play(url) self.playerController?.playUrl = url
self.playerController?.loadFile(url)
// Add external subtitles after the video is loaded
self.setInitialExternalSubtitles()
self.onVideoLoadEnd?(["target": self.reactTag as Any]) self.onVideoLoadEnd?(["target": self.reactTag as Any])
} else { } else {
self.onVideoError?(["error": "Invalid or empty URI"]) self.onVideoError?(["error": "Invalid or empty URI"])
@@ -172,8 +167,41 @@ class MpvPlayerView: ExpoView {
} }
func getAudioTracks() -> [[String: Any]] { func getAudioTracks() -> [[String: Any]] {
// Implementation would go here guard let playerController = playerController else {
return [] return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to audio tracks only
var audioTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "audio" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Audio \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
audioTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return audioTracks
} }
func setSubtitleTrack(_ trackIndex: Int) { func setSubtitleTrack(_ trackIndex: Int) {
@@ -181,20 +209,152 @@ class MpvPlayerView: ExpoView {
} }
func getSubtitleTracks() -> [[String: Any]] { func getSubtitleTracks() -> [[String: Any]] {
// Implementation would go here guard let playerController = playerController else {
return [] return []
}
// Get track list as a node
guard let trackListStr = playerController.getNode("track-list") else {
return []
}
// Parse the JSON string into an array
guard let data = trackListStr.data(using: .utf8),
let trackList = try? JSONSerialization.jsonObject(with: data) as? [Any]
else {
return []
}
// Filter to subtitle tracks only
var subtitleTracks: [[String: Any]] = []
for case let track as [String: Any] in trackList {
if let type = track["type"] as? String, type == "sub" {
let id = track["id"] as? Int ?? 0
let title = track["title"] as? String ?? "Subtitle \(id)"
let lang = track["lang"] as? String ?? "unknown"
let selected = track["selected"] as? Bool ?? false
subtitleTracks.append([
"id": id,
"title": title,
"language": lang,
"selected": selected,
])
}
}
return subtitleTracks
} }
func setSubtitleURL(_ subtitleURL: String, name: String) { func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else { return } guard let url = URL(string: subtitleURL) else { return }
print("Adding subtitle: \(name) from \(subtitleURL)")
// Add the subtitle file
playerController?.command("sub-add", args: [url.absoluteString]) playerController?.command("sub-add", args: [url.absoluteString])
} }
private func setInitialExternalSubtitles() {
if let externalSubtitles = self.externalSubtitles {
for subtitle in externalSubtitles {
if let subtitleName = subtitle["name"],
let subtitleURL = subtitle["DeliveryUrl"]
{
print("Adding external subtitle: \(subtitleName) from \(subtitleURL)")
setSubtitleURL(subtitleURL, name: subtitleName)
}
}
}
}
// MARK: - Private Methods // MARK: - Private Methods
private func handlePropertyChange(propertyName: String, value: Any?) { private func isPaused() -> Bool {
guard let playerController = playerController else { return } print("isPaused: \(playerController?.getFlag(MpvProperty.pause) ?? true)")
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Check if we already cleaned up
guard playerController != nil else { return }
// First stop playback
stop()
// Break reference cycles
playerController?.delegate = nil
// Remove from view hierarchy
playerController?.view.removeFromSuperview()
// Release references
playerController = nil
}
deinit {
cleanup()
}
// Reset the player when experiencing black screen or other issues
func resetPlayer() {
// Store current source
let currentSource = source
// Clean up existing player
cleanup()
// Create a new player
setupView()
// If we had a source, reload it
if let source = currentSource {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
// Check if player needs reset when the view appears
override func didMoveToWindow() {
super.didMoveToWindow()
// If we're returning to the window and player is missing, reset
if window != nil && playerController == nil {
setupView()
// Reload previous source if available
if let source = source {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.setSource(source)
}
}
}
}
}
// MARK: - MPV Player Delegate
extension MpvPlayerView: MpvPlayerDelegate {
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
switch propertyName { switch propertyName {
case MpvProperty.pausedForCache: case MpvProperty.pausedForCache:
let isBuffering = value as? Bool ?? false let isBuffering = value as? Bool ?? false
@@ -241,105 +401,6 @@ class MpvPlayerView: ExpoView {
break break
} }
} }
private func isPaused() -> Bool {
return playerController?.getFlag(MpvProperty.pause) ?? true
}
private func isBuffering() -> Bool {
return playerController?.getFlag(MpvProperty.pausedForCache) ?? false
}
private func getCurrentTime() -> Double {
return playerController?.getDouble(MpvProperty.timePosition) ?? 0
}
private func getVideoDuration() -> Double {
return playerController?.getDouble(MpvProperty.duration) ?? 0
}
// MARK: - Cleanup
override func removeFromSuperview() {
cleanup()
super.removeFromSuperview()
}
private func cleanup() {
// Ensure everything completes before cleanup
playerController?.mpvQueue.sync {}
// Stop playback
stop()
// Break reference cycles
coordinator?.player = nil
coordinator?.onPropertyChange = nil
playerController?.delegate = nil
// Remove from view hierarchy
hostingController?.view.removeFromSuperview()
hostingController = nil
coordinator = nil
playerController = nil
}
deinit {
cleanup()
}
}
// MARK: - SwiftUI Wrapper
struct MpvMetalPlayerView: UIViewControllerRepresentable {
@ObservedObject var coordinator: Coordinator
let existingController: MpvMetalViewController
init(coordinator: Coordinator, existingController: MpvMetalViewController) {
self.coordinator = coordinator
self.existingController = existingController
}
func makeUIViewController(context: Context) -> UIViewController {
return existingController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// Updates if needed
}
func makeCoordinator() -> Coordinator {
coordinator
}
// Method for playing media
func play(_ url: URL) -> Self {
coordinator.playUrl = url
return self
}
// Method for handling property changes
func onPropertyChange(_ handler: @escaping (MpvMetalViewController, String, Any?) -> Void)
-> Self
{
coordinator.onPropertyChange = handler
return self
}
@MainActor
final class Coordinator: MpvPlayerDelegate, ObservableObject {
weak var player: MpvMetalViewController?
var playUrl: URL?
var onPropertyChange: ((MpvMetalViewController, String, Any?) -> Void)?
func play(_ url: URL) {
player?.loadFile(url)
}
func propertyChanged(mpv: OpaquePointer, propertyName: String, value: Any?) {
guard let player = player else { return }
onPropertyChange?(player, propertyName, value)
}
}
} }
// MARK: - Player Controller // MARK: - Player Controller
@@ -403,36 +464,31 @@ final class MpvMetalViewController: UIViewController {
} }
deinit { deinit {
// Use syncQueue to ensure thread safety during shutdown // Flag that we're being deinitialized to prevent new callbacks
syncQueue.sync { isBeingDeallocated = true
// Mark as shutting down to prevent new callbacks from running
isShuttingDown = true
isBeingDeallocated = true
// Make sure to handle this on the mpv queue // Remove the wakeup callback first to prevent any new callbacks
mpvQueue.sync { if let mpv = self.mpv {
// First remove the wakeup callback to prevent any new callbacks mpv_set_wakeup_callback(mpv, nil, nil)
if let mpv = self.mpv { }
mpv_set_wakeup_callback(mpv, nil, nil)
}
// Release the container // Release the container
if let contextPtr = contextPointer { if let contextPtr = contextPointer {
let container = Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque( let container = Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque(
contextPtr contextPtr
).takeUnretainedValue() ).takeUnretainedValue()
container.invalidate() container.invalidate()
Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque(contextPtr) Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque(contextPtr)
.release() .release()
contextPointer = nil contextPointer = nil
} }
// Terminate and destroy mpv as the final step // Terminate and destroy mpv as the final step
if let mpv = self.mpv { if let mpv = self.mpv {
mpv_terminate_destroy(mpv) // Unobserve all properties
self.mpv = nil mpv_unobserve_property(mpv, 0)
} mpv_terminate_destroy(mpv)
} self.mpv = nil
} }
} }
@@ -462,12 +518,22 @@ final class MpvMetalViewController: UIViewController {
mpv_request_log_messages(mpvHandle, "no") mpv_request_log_messages(mpvHandle, "no")
#endif #endif
// Force a proper window setup to prevent black screens
mpv_set_option_string(mpvHandle, "force-window", "yes")
mpv_set_option_string(mpvHandle, "reset-on-next-file", "all")
// Set rendering options
mpv_set_option(mpvHandle, "wid", MPV_FORMAT_INT64, &metalLayer) mpv_set_option(mpvHandle, "wid", MPV_FORMAT_INT64, &metalLayer)
mpv_set_option_string(mpvHandle, "subs-match-os-language", "yes")
mpv_set_option_string(mpvHandle, "subs-fallback", "yes")
mpv_set_option_string(mpvHandle, "vo", "gpu-next") mpv_set_option_string(mpvHandle, "vo", "gpu-next")
mpv_set_option_string(mpvHandle, "gpu-api", "vulkan") mpv_set_option_string(mpvHandle, "gpu-api", "vulkan")
mpv_set_option_string(mpvHandle, "gpu-context", "auto")
mpv_set_option_string(mpvHandle, "hwdec", "videotoolbox") mpv_set_option_string(mpvHandle, "hwdec", "videotoolbox")
// Set subtitle options
mpv_set_option_string(mpvHandle, "subs-match-os-language", "yes")
mpv_set_option_string(mpvHandle, "subs-fallback", "yes")
// Set video options
mpv_set_option_string(mpvHandle, "video-rotate", "no") mpv_set_option_string(mpvHandle, "video-rotate", "no")
mpv_set_option_string(mpvHandle, "ytdl", "no") mpv_set_option_string(mpvHandle, "ytdl", "no")
@@ -487,29 +553,21 @@ final class MpvMetalViewController: UIViewController {
mpv_observe_property(mpvHandle, 0, MpvProperty.duration, MPV_FORMAT_DOUBLE) mpv_observe_property(mpvHandle, 0, MpvProperty.duration, MPV_FORMAT_DOUBLE)
mpv_observe_property(mpvHandle, 0, MpvProperty.pause, MPV_FORMAT_FLAG) mpv_observe_property(mpvHandle, 0, MpvProperty.pause, MPV_FORMAT_FLAG)
// Set up weak reference for callback with improved safety // Set up weak reference for callback
let container = WeakContainer(value: self) let container = WeakContainer(value: self)
contextPointer = Unmanaged.passRetained(container).toOpaque() contextPointer = Unmanaged.passRetained(container).toOpaque()
// Set wakeup callback with safer checking // Set wakeup callback
mpv_set_wakeup_callback( mpv_set_wakeup_callback(
mpvHandle, mpvHandle,
{ pointer in { pointer in
guard let ptr = pointer else { return } guard let ptr = pointer else { return }
// Get the container safely
let container = Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque(ptr) let container = Unmanaged<WeakContainer<MpvMetalViewController>>.fromOpaque(ptr)
.takeUnretainedValue() .takeUnretainedValue()
// Access the value with additional safety checks
DispatchQueue.main.async { DispatchQueue.main.async {
if let controller = container.value { if let controller = container.value, !controller.isBeingDeallocated {
if !controller.isBeingDeallocated { controller.processEvents()
controller.processEvents()
} else {
// If the controller is being deallocated, invalidate the container
container.invalidate()
}
} }
} }
}, contextPointer) }, contextPointer)
@@ -520,14 +578,12 @@ final class MpvMetalViewController: UIViewController {
func loadFile(_ url: URL) { func loadFile(_ url: URL) {
guard let mpv = mpv else { return } guard let mpv = mpv else { return }
print("Loading file: \(url.absoluteString)")
var args = [url.absoluteString, "replace"] var args = [url.absoluteString, "replace"]
command("loadfile", args: args) command("loadfile", args: args)
} }
func togglePause() {
getFlag(MpvProperty.pause) ? play() : pause()
}
func play() { func play() {
setFlag(MpvProperty.pause, false) setFlag(MpvProperty.pause, false)
} }
@@ -545,6 +601,15 @@ final class MpvMetalViewController: UIViewController {
return data return data
} }
func getNode(_ name: String) -> String? {
guard let mpv = mpv else { return nil }
guard let cString = mpv_get_property_string(mpv, name) else { return nil }
let string = String(cString: cString)
mpv_free(UnsafeMutableRawPointer(mutating: cString))
return string
}
func getString(_ name: String) -> String? { func getString(_ name: String) -> String? {
guard let mpv = mpv else { return nil } guard let mpv = mpv else { return nil }
@@ -613,34 +678,29 @@ final class MpvMetalViewController: UIViewController {
// MARK: - Event Processing // MARK: - Event Processing
private func processEvents() { private func processEvents() {
// Check shutdown state first before proceeding // Exit if we're being deallocated
if syncQueue.sync(execute: { isShuttingDown }) { if isBeingDeallocated {
return return
} }
mpvQueue.async { [weak self] in guard let mpv = mpv else { return }
guard let self = self,
let mpv = self.mpv,
!self.isBeingDeallocated,
!self.syncQueue.sync(execute: { self.isShuttingDown })
else {
return
}
while self.mpv != nil && !self.isBeingDeallocated // Process a limited number of events to avoid infinite loops
&& !self.syncQueue.sync(execute: { self.isShuttingDown }) let maxEvents = 10
{ var eventCount = 0
guard let event = mpv_wait_event(mpv, 0) else { break }
if event.pointee.event_id == MPV_EVENT_NONE { break }
self.handleEvent(event) while !isBeingDeallocated && eventCount < maxEvents {
} guard let event = mpv_wait_event(mpv, 0) else { break }
if event.pointee.event_id == MPV_EVENT_NONE { break }
handleEvent(event)
eventCount += 1
} }
} }
private func handleEvent(_ event: UnsafePointer<mpv_event>) { private func handleEvent(_ event: UnsafePointer<mpv_event>) {
// Exit early if we're shutting down // Exit early if we're being deallocated
if syncQueue.sync(execute: { isShuttingDown }) || isBeingDeallocated { if isBeingDeallocated {
return return
} }
@@ -684,13 +744,12 @@ final class MpvMetalViewController: UIViewController {
case MPV_EVENT_SHUTDOWN: case MPV_EVENT_SHUTDOWN:
print("MPV shutdown event received") print("MPV shutdown event received")
mpvQueue.async { [weak self] in // Let the deinit handle cleanup - just mark as deallocating
guard let self = self, self.mpv != nil else { return } isBeingDeallocated = true
mpv_terminate_destroy(self.mpv)
self.mpv = nil
}
case MPV_EVENT_LOG_MESSAGE: case MPV_EVENT_LOG_MESSAGE:
return return
default: default:
if let eventName = mpv_event_name(event.pointee.event_id) { if let eventName = mpv_event_name(event.pointee.event_id) {
print("MPV event: \(String(cString: eventName))") print("MPV event: \(String(cString: eventName))")