From 1daded6ec6689fb4ece936ab09ceb2a2d0693f59 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:15:57 +0100 Subject: [PATCH] ios --- ios/Podfile.lock | 24 - ios/Runner.xcodeproj/project.pbxproj | 40 ++ .../xcshareddata/swiftpm/Package.resolved | 14 + .../xcshareddata/swiftpm/Package.resolved | 14 + ios/Runner/AppDelegate.swift | 5 + ios/Runner/MpvPlayer/MpvPlayerCore.swift | 411 ++++++++++++++ ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 235 ++++++++ lib/mpv/src/player/mpv_player.dart | 7 +- lib/mpv/src/player/mpv_player_ios.dart | 5 + lib/mpv/src/player/mpv_player_macos.dart | 509 +----------------- lib/mpv/src/player/mpv_player_native.dart | 506 +++++++++++++++++ lib/mpv/src/video/mpv_video.dart | 42 +- lib/screens/video_player_screen.dart | 3 +- 13 files changed, 1244 insertions(+), 571 deletions(-) create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ios/Runner/MpvPlayer/MpvPlayerCore.swift create mode 100644 ios/Runner/MpvPlayer/MpvPlayerPlugin.swift create mode 100644 lib/mpv/src/player/mpv_player_ios.dart create mode 100644 lib/mpv/src/player/mpv_player_native.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 274c7182..971fa505 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2,10 +2,6 @@ PODS: - connectivity_plus (0.0.1): - Flutter - Flutter (1.0.0) - - media_kit_libs_ios_video (1.0.4): - - Flutter - - media_kit_video (0.0.1): - - Flutter - os_media_controls (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -21,34 +17,22 @@ PODS: - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter - - volume_controller (0.0.1): - - Flutter - - wakelock_plus (0.0.1): - - Flutter DEPENDENCIES: - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - volume_controller (from `.symlinks/plugins/volume_controller/ios`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) EXTERNAL SOURCES: connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter - media_kit_libs_ios_video: - :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" - media_kit_video: - :path: ".symlinks/plugins/media_kit_video/ios" os_media_controls: :path: ".symlinks/plugins/os_media_controls/ios" package_info_plus: @@ -61,24 +45,16 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" - volume_controller: - :path: ".symlinks/plugins/volume_controller/ios" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 - media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 099583b9..4976f83d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,9 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C12D873DE8572579684A82C /* Pods_Runner.framework */; }; 6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; }; + 6A8A46212EDB320D0057B88C /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit-GPL */; }; + 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; }; + 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -52,6 +55,8 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 5B81FA7048BFA976F49D555E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; + 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; + 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -81,6 +86,7 @@ buildActionMask = 2147483647; files = ( 50DEEA8E23D1B433DE5341D6 /* Pods_Runner.framework in Frameworks */, + 6A8A46212EDB320D0057B88C /* MPVKit-GPL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -104,6 +110,15 @@ name = Frameworks; sourceTree = ""; }; + 6A8A46242EDB370C0057B88C /* MpvPlayer */ = { + isa = PBXGroup; + children = ( + 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */, + 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */, + ); + path = MpvPlayer; + sourceTree = ""; + }; 875C8AEFBBA3759A1849D674 /* Pods */ = { isa = PBXGroup; children = ( @@ -153,6 +168,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 6A8A46242EDB370C0057B88C /* MpvPlayer */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, @@ -237,6 +253,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -376,6 +395,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */, + 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -734,6 +755,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/edde746/MPVKit"; + requirement = { + kind = revision; + revision = a8ae0d2f08df6188c662b7670bddb97131db8c5a; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 6A8A46202EDB320D0057B88C /* MPVKit-GPL */ = { + isa = XCSwiftPackageProductDependency; + package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */; + productName = "MPVKit-GPL"; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..be7df7e5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee", + "pins" : [ + { + "identity" : "mpvkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/edde746/MPVKit", + "state" : { + "revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a" + } + } + ], + "version" : 3 +} diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..be7df7e5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee", + "pins" : [ + { + "identity" : "mpvkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/edde746/MPVKit", + "state" : { + "revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a" + } + } + ], + "version" : 3 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d12f1afe..432d1012 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,11 @@ import AVFoundation ) -> Bool { GeneratedPluginRegistrant.register(with: self) + // Register MPV player plugin + if let registrar = self.registrar(forPlugin: "MpvPlayerPlugin") { + MpvPlayerPlugin.register(with: registrar) + } + // Configure audio session for media playback do { let session = AVAudioSession.sharedInstance() diff --git a/ios/Runner/MpvPlayer/MpvPlayerCore.swift b/ios/Runner/MpvPlayer/MpvPlayerCore.swift new file mode 100644 index 00000000..2e299ba6 --- /dev/null +++ b/ios/Runner/MpvPlayer/MpvPlayerCore.swift @@ -0,0 +1,411 @@ +import UIKit +import Libmpv + +/// Protocol for receiving player events +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 +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 + } + } + } + } +} + +/// Core MPV player using Metal rendering for iOS +class MpvPlayerCore: NSObject { + + // MARK: - Properties + + private var metalLayer: MetalLayer? + private var mpv: OpaquePointer? + private weak var window: UIWindow? + private lazy var queue = DispatchQueue(label: "mpv", qos: .userInitiated) + + weak var delegate: MpvPlayerDelegate? + + private(set) var isInitialized = false + + // MARK: - Initialization + + func initialize(in window: UIWindow) -> Bool { + guard !isInitialized else { + print("[MpvPlayerCore] Already initialized") + return true + } + + self.window = window + + // Create Metal layer for video rendering + let layer = MetalLayer() + layer.frame = window.bounds + layer.contentsScale = UIScreen.main.nativeScale + layer.framebufferOnly = true + layer.backgroundColor = UIColor.black.cgColor + + metalLayer = layer + + // Add Metal layer to WINDOW layer (behind Flutter's view controller) + window.layer.insertSublayer(layer, at: 0) + + print("[MpvPlayerCore] Metal layer added to window, frame: \(layer.frame)") + + // Initialize MPV with this Metal layer + guard setupMpv() else { + print("[MpvPlayerCore] Failed to setup MPV") + layer.removeFromSuperlayer() + metalLayer = nil + return false + } + + // Setup background/foreground notifications + setupNotifications() + + isInitialized = true + print("[MpvPlayerCore] Initialized successfully with MPV") + return true + } + + 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")) + + // Initialize MPV + let initResult = mpv_initialize(mpv) + if initResult < 0 { + print("[MpvPlayerCore] mpv_initialize failed: \(String(cString: mpv_error_string(initResult)))") + mpv_terminate_destroy(mpv) + mpv = nil + return false + } + + // Set up wakeup callback for event handling + mpv_set_wakeup_callback(mpv, { ctx in + let core = Unmanaged.fromOpaque(ctx!).takeUnretainedValue() + core.readEvents() + }, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())) + + print("[MpvPlayerCore] MPV initialized successfully") + return true + } + + // MARK: - Background/Foreground Handling + + private func setupNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(enterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(enterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func enterBackground() { + // Disable video output to fix black screen when returning from background + print("[MpvPlayerCore] Entering background - disabling video") + if mpv != nil { + mpv_set_option_string(mpv, "vid", "no") + } + } + + @objc private func enterForeground() { + // Re-enable video output + print("[MpvPlayerCore] Entering foreground - enabling video") + if mpv != nil { + mpv_set_option_string(mpv, "vid", "auto") + } + } + + // MARK: - MPV Properties and Commands + + func setProperty(_ name: String, value: String) { + guard mpv != nil else { return } + mpv_set_property_string(mpv, name, value) + } + + 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())) + } + + // MARK: - Visibility + + func setVisible(_ visible: Bool) { + guard let layer = metalLayer else { return } + + if visible { + // Re-insert at the bottom of the window layer stack + layer.removeFromSuperlayer() + if let windowLayer = window?.layer { + windowLayer.insertSublayer(layer, at: 0) + } + } + + layer.isHidden = !visible + print("[MpvPlayerCore] setVisible(\(visible))") + } + + func updateFrame(_ frame: CGRect? = nil) { + guard let metalLayer = metalLayer else { return } + + if let frame = frame { + metalLayer.frame = frame + } else if let window = window { + metalLayer.frame = window.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 + ) + + print("[MpvPlayerCore] updateFrame: \(metalLayer.frame)") + } + + // MARK: - Private Helpers + + private func command(_ cmd: String, args: [String] = []) { + guard mpv != nil else { return } + + // Build array of C strings for mpv_command + var cargs: [UnsafeMutablePointer?] = ([cmd] + args).map { strdup($0) } + cargs.append(nil) // null-terminate + defer { + for ptr in cargs { + free(ptr) + } + } + + // mpv_command expects UnsafePointer, use withUnsafeBufferPointer for the conversion + cargs.withUnsafeBufferPointer { buffer in + var constPtrs = buffer.map { UnsafePointer($0) } + _ = mpv_command(mpv, &constPtrs) + } + } + + private func readEvents() { + queue.async { [weak self] in + guard let self = self, 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_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_LOG_MESSAGE: + if let msgPtr = event.data?.assumingMemoryBound(to: mpv_event_log_message.self) { + let msg = msgPtr.pointee + if let prefix = msg.prefix, let level = msg.level, let text = msg.text { + print("[MPV:\(String(cString: prefix))] \(String(cString: level)): \(String(cString: text))", terminator: "") + } + } + + default: + break + } + } + + private func handlePropertyChange(name: String, property: mpv_event_property) { + var value: Any? + + switch property.format { + case MPV_FORMAT_DOUBLE: + if let ptr = property.data { + value = ptr.assumingMemoryBound(to: Double.self).pointee + } + + case MPV_FORMAT_FLAG: + if let ptr = property.data { + value = ptr.assumingMemoryBound(to: Int32.self).pointee != 0 + } + + case MPV_FORMAT_NODE: + if let ptr = property.data { + let node = ptr.assumingMemoryBound(to: mpv_node.self).pointee + value = convertNode(node) + } + + case MPV_FORMAT_STRING: + if let ptr = property.data { + let cstr = ptr.assumingMemoryBound(to: UnsafePointer?.self).pointee + value = cstr.map { String(cString: $0) } + } + + default: + break + } + + 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 { String(cString: $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.. FlutterError? { + self.eventSink = events + print("[MpvPlayerPlugin] Event stream connected") + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + print("[MpvPlayerPlugin] Event stream disconnected") + return nil + } + + // MARK: - FlutterPlugin Method Handler + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initialize": + handleInitialize(result: result) + + case "dispose": + handleDispose(result: result) + + case "setProperty": + handleSetProperty(call: call, result: result) + + case "getProperty": + handleGetProperty(call: call, result: result) + + case "observeProperty": + handleObserveProperty(call: call, result: result) + + case "command": + handleCommand(call: call, result: result) + + case "setVisible": + handleSetVisible(call: call, result: result) + + case "isInitialized": + result(playerCore?.isInitialized ?? false) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Method Handlers + + private func handleInitialize(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { + result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil)) + return + } + + // Check if already initialized + if self.playerCore?.isInitialized == true { + print("[MpvPlayerPlugin] Already initialized") + result(true) + return + } + + // Find the key window + guard let window = self.findKeyWindow() else { + print("[MpvPlayerPlugin] Failed to find key window") + result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil)) + return + } + + // Create and initialize player core + let core = MpvPlayerCore() + core.delegate = self + + guard core.initialize(in: window) else { + print("[MpvPlayerPlugin] Failed to initialize MPV") + result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil)) + return + } + + self.playerCore = core + + // Start hidden + core.setVisible(false) + + print("[MpvPlayerPlugin] Initialized successfully") + result(true) + } + } + + private func handleDispose(result: @escaping FlutterResult) { + DispatchQueue.main.async { [weak self] in + self?.playerCore?.dispose() + self?.playerCore = nil + print("[MpvPlayerPlugin] Disposed") + result(nil) + } + } + + private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let value = args["value"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil)) + return + } + + playerCore?.setProperty(name, value: value) + result(nil) + } + + private func handleGetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let name = args["name"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' argument", details: nil)) + return + } + + let value = playerCore?.getProperty(name) + result(value) + } + + private func handleObserveProperty(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let name = args["name"] as? String, + let format = args["format"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'format' argument", details: nil)) + return + } + + playerCore?.observeProperty(name, format: format) + result(nil) + } + + private func handleCommand(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let commandArgs = args["args"] as? [String] else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing 'args' argument", details: nil)) + return + } + + playerCore?.command(commandArgs) + result(nil) + } + + private func handleSetVisible(call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let args = call.arguments as? [String: Any], + let visible = args["visible"] as? Bool else { + result(FlutterError(code: "INVALID_ARGS", message: "Missing 'visible' argument", details: nil)) + return + } + + DispatchQueue.main.async { [weak self] in + self?.playerCore?.setVisible(visible) + + // Update frame when becoming visible + if visible { + self?.playerCore?.updateFrame() + } + + result(nil) + } + } + + // MARK: - MpvPlayerDelegate + + func onPropertyChange(name: String, value: Any?) { + guard let eventSink = eventSink else { return } + + var event: [String: Any] = ["type": "property", "name": name] + + if let value = value { + event["value"] = value + } + + eventSink(event) + } + + func onEvent(name: String, data: [String: Any]?) { + guard let eventSink = eventSink else { return } + + var event: [String: Any] = ["type": "event", "name": name] + if let data = data { + event["data"] = data + } + + eventSink(event) + } + + // MARK: - Helpers + + private func findKeyWindow() -> UIWindow? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { + return nil + } + return window + } +} diff --git a/lib/mpv/src/player/mpv_player.dart b/lib/mpv/src/player/mpv_player.dart index 23b05c3e..37825d99 100644 --- a/lib/mpv/src/player/mpv_player.dart +++ b/lib/mpv/src/player/mpv_player.dart @@ -4,6 +4,7 @@ import '../models/mpv_audio_device.dart'; import '../models/mpv_media.dart'; import '../models/mpv_audio_track.dart'; import '../models/mpv_subtitle_track.dart'; +import 'mpv_player_ios.dart'; import 'mpv_player_macos.dart'; import 'mpv_player_state.dart'; import 'mpv_player_streams.dart'; @@ -187,12 +188,16 @@ abstract class MpvPlayer { /// /// Returns a platform-specific implementation: /// - macOS: [MpvPlayerMacOS] using MPVKit with Metal rendering + /// - iOS: [MpvPlayerIOS] using MPVKit with Metal rendering /// - Other platforms: [MpvPlayerStub] (placeholder) factory MpvPlayer() { if (Platform.isMacOS) { return MpvPlayerMacOS(); } - // Future: Add iOS, Android, Windows, Linux implementations + if (Platform.isIOS) { + return MpvPlayerIOS(); + } + // Future: Add Android, Windows, Linux implementations return MpvPlayerStub(); } } diff --git a/lib/mpv/src/player/mpv_player_ios.dart b/lib/mpv/src/player/mpv_player_ios.dart new file mode 100644 index 00000000..6eba1c9b --- /dev/null +++ b/lib/mpv/src/player/mpv_player_ios.dart @@ -0,0 +1,5 @@ +import 'mpv_player_native.dart'; + +/// iOS implementation of MpvPlayer. +/// Inherits all functionality from MpvPlayerNative. +class MpvPlayerIOS extends MpvPlayerNative {} diff --git a/lib/mpv/src/player/mpv_player_macos.dart b/lib/mpv/src/player/mpv_player_macos.dart index a586ed73..76d7b1bc 100644 --- a/lib/mpv/src/player/mpv_player_macos.dart +++ b/lib/mpv/src/player/mpv_player_macos.dart @@ -1,506 +1,5 @@ -import 'dart:async'; +import 'mpv_player_native.dart'; -import 'package:flutter/services.dart'; - -import '../models/mpv_audio_device.dart'; -import '../models/mpv_audio_track.dart'; -import '../models/mpv_log.dart'; -import '../models/mpv_log_level.dart'; -import '../models/mpv_media.dart'; -import '../models/mpv_subtitle_track.dart'; -import '../models/mpv_track_selection.dart'; -import '../models/mpv_tracks.dart'; -import 'mpv_player.dart'; -import 'mpv_player_state.dart'; -import 'mpv_player_streams.dart'; - -/// macOS implementation of [MpvPlayer] using MPVKit via platform channels. -class MpvPlayerMacOS implements MpvPlayer { - static const _methodChannel = MethodChannel('com.plezy/mpv_player'); - static const _eventChannel = EventChannel('com.plezy/mpv_player/events'); - - MpvPlayerState _state = const MpvPlayerState(); - - @override - MpvPlayerState get state => _state; - - late final MpvPlayerStreams _streams; - - @override - MpvPlayerStreams get streams => _streams; - - @override - int? get textureId => null; // macOS uses direct Metal layer, not Flutter texture - - // Stream controllers - final _playingController = StreamController.broadcast(); - final _completedController = StreamController.broadcast(); - final _bufferingController = StreamController.broadcast(); - final _positionController = StreamController.broadcast(); - final _durationController = StreamController.broadcast(); - final _bufferController = StreamController.broadcast(); - final _volumeController = StreamController.broadcast(); - final _rateController = StreamController.broadcast(); - final _tracksController = StreamController.broadcast(); - final _trackController = StreamController.broadcast(); - final _logController = StreamController.broadcast(); - final _errorController = StreamController.broadcast(); - final _audioDeviceController = StreamController.broadcast(); - final _audioDevicesController = - StreamController>.broadcast(); - - StreamSubscription? _eventSubscription; - bool _disposed = false; - bool _initialized = false; - bool _isVisible = false; - - MpvPlayerMacOS() { - _streams = MpvPlayerStreams( - playing: _playingController.stream, - completed: _completedController.stream, - buffering: _bufferingController.stream, - position: _positionController.stream, - duration: _durationController.stream, - buffer: _bufferController.stream, - volume: _volumeController.stream, - rate: _rateController.stream, - tracks: _tracksController.stream, - track: _trackController.stream, - log: _logController.stream, - error: _errorController.stream, - audioDevice: _audioDeviceController.stream, - audioDevices: _audioDevicesController.stream, - ); - - _setupEventListener(); - } - - void _setupEventListener() { - _eventSubscription = _eventChannel.receiveBroadcastStream().listen( - _handleEvent, - onError: (error) { - _errorController.add(error.toString()); - }, - ); - } - - void _handleEvent(dynamic event) { - if (event is! Map) return; - - final type = event['type'] as String?; - final name = event['name'] as String?; - - if (type == 'property' && name != null) { - _handlePropertyChange(name, event['value']); - } else if (type == 'event' && name != null) { - _handleMpvEvent(name, event['data'] as Map?); - } - } - - void _handlePropertyChange(String name, dynamic value) { - switch (name) { - case 'pause': - final playing = value == false; - _state = _state.copyWith(playing: playing); - _playingController.add(playing); - break; - - case 'eof-reached': - final completed = value == true; - _state = _state.copyWith(completed: completed); - _completedController.add(completed); - break; - - case 'paused-for-cache': - final buffering = value == true; - _state = _state.copyWith(buffering: buffering); - _bufferingController.add(buffering); - break; - - case 'time-pos': - if (value is num) { - final position = Duration(milliseconds: (value * 1000).toInt()); - _state = _state.copyWith(position: position); - _positionController.add(position); - } - break; - - case 'duration': - if (value is num) { - final duration = Duration(milliseconds: (value * 1000).toInt()); - _state = _state.copyWith(duration: duration); - _durationController.add(duration); - } - break; - - case 'demuxer-cache-time': - if (value is num) { - final buffer = Duration(milliseconds: (value * 1000).toInt()); - _state = _state.copyWith(buffer: buffer); - _bufferController.add(buffer); - } - break; - - case 'volume': - if (value is num) { - final volume = value.toDouble(); - _state = _state.copyWith(volume: volume); - _volumeController.add(volume); - } - break; - - case 'speed': - if (value is num) { - final rate = value.toDouble(); - _state = _state.copyWith(rate: rate); - _rateController.add(rate); - } - break; - - case 'track-list': - if (value is List) { - final tracks = _parseTrackList(value); - _state = _state.copyWith(tracks: tracks); - _tracksController.add(tracks); - } - break; - - case 'aid': - _updateSelectedAudioTrack(value); - break; - - case 'sid': - _updateSelectedSubtitleTrack(value); - break; - } - } - - void _handleMpvEvent(String name, Map? data) { - switch (name) { - case 'end-file': - final reason = data?['reason'] as String?; - if (reason == 'eof') { - _state = _state.copyWith(completed: true); - _completedController.add(true); - } else if (reason == 'error') { - _errorController.add('Playback error'); - } - break; - - case 'file-loaded': - // Reset completed state when new file is loaded - _state = _state.copyWith(completed: false); - _completedController.add(false); - break; - } - } - - MpvTracks _parseTrackList(List trackList) { - final audioTracks = []; - final subtitleTracks = []; - - for (final track in trackList) { - if (track is! Map) continue; - - final type = track['type'] as String?; - final id = track['id']?.toString() ?? ''; - - if (type == 'audio') { - audioTracks.add(MpvAudioTrack( - id: id, - title: track['title'] as String?, - language: track['lang'] as String?, - codec: track['codec'] as String?, - channels: (track['demux-channel-count'] as num?)?.toInt(), - sampleRate: (track['demux-samplerate'] as num?)?.toInt(), - isDefault: track['default'] as bool? ?? false, - )); - } else if (type == 'sub') { - subtitleTracks.add(MpvSubtitleTrack( - id: id, - title: track['title'] as String?, - language: track['lang'] as String?, - codec: track['codec'] as String?, - isExternal: track['external'] as bool? ?? false, - uri: track['external-filename'] as String?, - )); - } - } - - return MpvTracks(audio: audioTracks, subtitle: subtitleTracks); - } - - void _updateSelectedAudioTrack(dynamic trackId) { - final id = trackId?.toString(); - MpvAudioTrack? selectedTrack; - - if (id != null && id != 'no') { - selectedTrack = _state.tracks.audio.cast().firstWhere( - (t) => t?.id == id, - orElse: () => null, - ); - } - - _state = _state.copyWith( - track: _state.track.copyWith(audio: selectedTrack), - ); - _trackController.add(_state.track); - } - - void _updateSelectedSubtitleTrack(dynamic trackId) { - final id = trackId?.toString(); - MpvSubtitleTrack? selectedTrack; - - if (id != null && id != 'no') { - selectedTrack = - _state.tracks.subtitle.cast().firstWhere( - (t) => t?.id == id, - orElse: () => null, - ); - } - - _state = _state.copyWith( - track: _state.track.copyWith(subtitle: selectedTrack), - ); - _trackController.add(_state.track); - } - - Future _ensureInitialized() async { - if (_initialized) return; - - try { - final result = await _methodChannel.invokeMethod('initialize'); - _initialized = result == true; - if (!_initialized) { - throw Exception('Failed to initialize MPV player'); - } - - // Subscribe to MPV properties - await _observeProperty('time-pos', 'double'); - await _observeProperty('duration', 'double'); - await _observeProperty('pause', 'flag'); - await _observeProperty('paused-for-cache', 'flag'); - await _observeProperty('track-list', 'node'); - await _observeProperty('eof-reached', 'flag'); - await _observeProperty('volume', 'double'); - await _observeProperty('aid', 'string'); - await _observeProperty('sid', 'string'); - } catch (e) { - _errorController.add('Initialization failed: $e'); - rethrow; - } - } - - Future _observeProperty(String name, String format) async { - await _methodChannel.invokeMethod('observeProperty', { - 'name': name, - 'format': format, - }); - } - - void _checkDisposed() { - if (_disposed) { - throw StateError('MpvPlayer has been disposed'); - } - } - - // ============================================ - // Playback Control - // ============================================ - - @override - Future open(MpvMedia media, {bool play = true}) async { - _checkDisposed(); - await _ensureInitialized(); - - // Show the video layer - await _methodChannel.invokeMethod('setVisible', {'visible': true}); - _isVisible = true; - - await command(['loadfile', media.uri, 'replace']); - if (!play) { - await setProperty('pause', 'yes'); - } - } - - @override - Future play() async { - _checkDisposed(); - await setProperty('pause', 'no'); - } - - @override - Future pause() async { - _checkDisposed(); - await setProperty('pause', 'yes'); - } - - @override - Future playOrPause() async { - _checkDisposed(); - if (_state.playing) { - await pause(); - } else { - await play(); - } - } - - @override - Future stop() async { - _checkDisposed(); - await command(['stop']); - await _methodChannel.invokeMethod('setVisible', {'visible': false}); - _isVisible = false; - } - - @override - Future seek(Duration position) async { - _checkDisposed(); - await command([ - 'seek', - (position.inMilliseconds / 1000.0).toString(), - 'absolute', - ]); - } - - // ============================================ - // Track Selection - // ============================================ - - @override - Future selectAudioTrack(MpvAudioTrack track) async { - _checkDisposed(); - await setProperty('aid', track.id); - } - - @override - Future selectSubtitleTrack(MpvSubtitleTrack track) async { - _checkDisposed(); - await setProperty('sid', track.id); - } - - @override - Future addSubtitleTrack({ - required String uri, - String? title, - String? language, - bool select = false, - }) async { - _checkDisposed(); - final args = ['sub-add', uri, select ? 'select' : 'auto']; - if (title != null) args.add('title=$title'); - if (language != null) args.add('lang=$language'); - await command(args); - } - - // ============================================ - // Volume and Rate - // ============================================ - - @override - Future setVolume(double volume) async { - _checkDisposed(); - await setProperty('volume', volume.toString()); - } - - @override - Future setRate(double rate) async { - _checkDisposed(); - await setProperty('speed', rate.toString()); - } - - @override - Future setAudioDevice(MpvAudioDevice device) async { - _checkDisposed(); - await setProperty('audio-device', device.name); - } - - // ============================================ - // MPV Properties - // ============================================ - - @override - Future setProperty(String name, String value) async { - _checkDisposed(); - await _methodChannel.invokeMethod('setProperty', { - 'name': name, - 'value': value, - }); - } - - @override - Future getProperty(String name) async { - _checkDisposed(); - return await _methodChannel.invokeMethod('getProperty', { - 'name': name, - }); - } - - @override - Future command(List args) async { - _checkDisposed(); - await _methodChannel.invokeMethod('command', {'args': args}); - } - - // ============================================ - // Passthrough - // ============================================ - - @override - Future setAudioPassthrough(bool enabled) async { - _checkDisposed(); - if (enabled) { - await setProperty('audio-spdif', 'ac3,eac3,dts,dts-hd,truehd'); - await setProperty('audio-exclusive', 'yes'); - } else { - await setProperty('audio-spdif', ''); - await setProperty('audio-exclusive', 'no'); - } - } - - // ============================================ - // Visibility - // ============================================ - - @override - Future setVisible(bool visible) async { - _checkDisposed(); - - try { - await _methodChannel.invokeMethod('setVisible', {'visible': visible}); - _isVisible = visible; - return true; - } catch (e) { - _errorController.add('Failed to set visibility: $e'); - return false; - } - } - - // ============================================ - // Lifecycle - // ============================================ - - @override - Future dispose() async { - if (_disposed) return; - _disposed = true; - - await _eventSubscription?.cancel(); - await _methodChannel.invokeMethod('dispose'); - - await _playingController.close(); - await _completedController.close(); - await _bufferingController.close(); - await _positionController.close(); - await _durationController.close(); - await _bufferController.close(); - await _volumeController.close(); - await _rateController.close(); - await _tracksController.close(); - await _trackController.close(); - await _logController.close(); - await _errorController.close(); - await _audioDeviceController.close(); - await _audioDevicesController.close(); - } -} +/// macOS implementation of MpvPlayer. +/// Inherits all functionality from MpvPlayerNative. +class MpvPlayerMacOS extends MpvPlayerNative {} diff --git a/lib/mpv/src/player/mpv_player_native.dart b/lib/mpv/src/player/mpv_player_native.dart new file mode 100644 index 00000000..2889b389 --- /dev/null +++ b/lib/mpv/src/player/mpv_player_native.dart @@ -0,0 +1,506 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../models/mpv_audio_device.dart'; +import '../models/mpv_audio_track.dart'; +import '../models/mpv_log.dart'; +import '../models/mpv_media.dart'; +import '../models/mpv_subtitle_track.dart'; +import '../models/mpv_track_selection.dart'; +import '../models/mpv_tracks.dart'; +import 'mpv_player.dart'; +import 'mpv_player_state.dart'; +import 'mpv_player_streams.dart'; + +/// Shared native implementation of [MpvPlayer] for iOS and macOS. +/// Uses MPVKit via platform channels with Metal rendering. +class MpvPlayerNative implements MpvPlayer { + static const _methodChannel = MethodChannel('com.plezy/mpv_player'); + static const _eventChannel = EventChannel('com.plezy/mpv_player/events'); + + MpvPlayerState _state = const MpvPlayerState(); + + @override + MpvPlayerState get state => _state; + + late final MpvPlayerStreams _streams; + + @override + MpvPlayerStreams get streams => _streams; + + @override + int? get textureId => null; // Uses direct Metal layer, not Flutter texture + + // Stream controllers + final _playingController = StreamController.broadcast(); + final _completedController = StreamController.broadcast(); + final _bufferingController = StreamController.broadcast(); + final _positionController = StreamController.broadcast(); + final _durationController = StreamController.broadcast(); + final _bufferController = StreamController.broadcast(); + final _volumeController = StreamController.broadcast(); + final _rateController = StreamController.broadcast(); + final _tracksController = StreamController.broadcast(); + final _trackController = StreamController.broadcast(); + final _logController = StreamController.broadcast(); + final _errorController = StreamController.broadcast(); + final _audioDeviceController = StreamController.broadcast(); + final _audioDevicesController = + StreamController>.broadcast(); + + StreamSubscription? _eventSubscription; + bool _disposed = false; + bool _initialized = false; + bool _isVisible = false; + + MpvPlayerNative() { + _streams = MpvPlayerStreams( + playing: _playingController.stream, + completed: _completedController.stream, + buffering: _bufferingController.stream, + position: _positionController.stream, + duration: _durationController.stream, + buffer: _bufferController.stream, + volume: _volumeController.stream, + rate: _rateController.stream, + tracks: _tracksController.stream, + track: _trackController.stream, + log: _logController.stream, + error: _errorController.stream, + audioDevice: _audioDeviceController.stream, + audioDevices: _audioDevicesController.stream, + ); + + _setupEventListener(); + } + + void _setupEventListener() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + _handleEvent, + onError: (error) { + _errorController.add(error.toString()); + }, + ); + } + + void _handleEvent(dynamic event) { + if (event is! Map) return; + + final type = event['type'] as String?; + final name = event['name'] as String?; + + if (type == 'property' && name != null) { + _handlePropertyChange(name, event['value']); + } else if (type == 'event' && name != null) { + _handleMpvEvent(name, event['data'] as Map?); + } + } + + void _handlePropertyChange(String name, dynamic value) { + switch (name) { + case 'pause': + final playing = value == false; + _state = _state.copyWith(playing: playing); + _playingController.add(playing); + break; + + case 'eof-reached': + final completed = value == true; + _state = _state.copyWith(completed: completed); + _completedController.add(completed); + break; + + case 'paused-for-cache': + final buffering = value == true; + _state = _state.copyWith(buffering: buffering); + _bufferingController.add(buffering); + break; + + case 'time-pos': + if (value is num) { + final position = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(position: position); + _positionController.add(position); + } + break; + + case 'duration': + if (value is num) { + final duration = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(duration: duration); + _durationController.add(duration); + } + break; + + case 'demuxer-cache-time': + if (value is num) { + final buffer = Duration(milliseconds: (value * 1000).toInt()); + _state = _state.copyWith(buffer: buffer); + _bufferController.add(buffer); + } + break; + + case 'volume': + if (value is num) { + final volume = value.toDouble(); + _state = _state.copyWith(volume: volume); + _volumeController.add(volume); + } + break; + + case 'speed': + if (value is num) { + final rate = value.toDouble(); + _state = _state.copyWith(rate: rate); + _rateController.add(rate); + } + break; + + case 'track-list': + if (value is List) { + final tracks = _parseTrackList(value); + _state = _state.copyWith(tracks: tracks); + _tracksController.add(tracks); + } + break; + + case 'aid': + _updateSelectedAudioTrack(value); + break; + + case 'sid': + _updateSelectedSubtitleTrack(value); + break; + } + } + + void _handleMpvEvent(String name, Map? data) { + switch (name) { + case 'end-file': + final reason = data?['reason'] as String?; + if (reason == 'eof') { + _state = _state.copyWith(completed: true); + _completedController.add(true); + } else if (reason == 'error') { + _errorController.add('Playback error'); + } + break; + + case 'file-loaded': + // Reset completed state when new file is loaded + _state = _state.copyWith(completed: false); + _completedController.add(false); + break; + } + } + + MpvTracks _parseTrackList(List trackList) { + final audioTracks = []; + final subtitleTracks = []; + + for (final track in trackList) { + if (track is! Map) continue; + + final type = track['type'] as String?; + final id = track['id']?.toString() ?? ''; + + if (type == 'audio') { + audioTracks.add(MpvAudioTrack( + id: id, + title: track['title'] as String?, + language: track['lang'] as String?, + codec: track['codec'] as String?, + channels: (track['demux-channel-count'] as num?)?.toInt(), + sampleRate: (track['demux-samplerate'] as num?)?.toInt(), + isDefault: track['default'] as bool? ?? false, + )); + } else if (type == 'sub') { + subtitleTracks.add(MpvSubtitleTrack( + id: id, + title: track['title'] as String?, + language: track['lang'] as String?, + codec: track['codec'] as String?, + isExternal: track['external'] as bool? ?? false, + uri: track['external-filename'] as String?, + )); + } + } + + return MpvTracks(audio: audioTracks, subtitle: subtitleTracks); + } + + void _updateSelectedAudioTrack(dynamic trackId) { + final id = trackId?.toString(); + MpvAudioTrack? selectedTrack; + + if (id != null && id != 'no') { + selectedTrack = _state.tracks.audio.cast().firstWhere( + (t) => t?.id == id, + orElse: () => null, + ); + } + + _state = _state.copyWith( + track: _state.track.copyWith(audio: selectedTrack), + ); + _trackController.add(_state.track); + } + + void _updateSelectedSubtitleTrack(dynamic trackId) { + final id = trackId?.toString(); + MpvSubtitleTrack? selectedTrack; + + if (id != null && id != 'no') { + selectedTrack = + _state.tracks.subtitle.cast().firstWhere( + (t) => t?.id == id, + orElse: () => null, + ); + } + + _state = _state.copyWith( + track: _state.track.copyWith(subtitle: selectedTrack), + ); + _trackController.add(_state.track); + } + + Future _ensureInitialized() async { + if (_initialized) return; + + try { + final result = await _methodChannel.invokeMethod('initialize'); + _initialized = result == true; + if (!_initialized) { + throw Exception('Failed to initialize MPV player'); + } + + // Subscribe to MPV properties + await _observeProperty('time-pos', 'double'); + await _observeProperty('duration', 'double'); + await _observeProperty('pause', 'flag'); + await _observeProperty('paused-for-cache', 'flag'); + await _observeProperty('track-list', 'node'); + await _observeProperty('eof-reached', 'flag'); + await _observeProperty('volume', 'double'); + await _observeProperty('aid', 'string'); + await _observeProperty('sid', 'string'); + } catch (e) { + _errorController.add('Initialization failed: $e'); + rethrow; + } + } + + Future _observeProperty(String name, String format) async { + await _methodChannel.invokeMethod('observeProperty', { + 'name': name, + 'format': format, + }); + } + + void _checkDisposed() { + if (_disposed) { + throw StateError('MpvPlayer has been disposed'); + } + } + + // ============================================ + // Playback Control + // ============================================ + + @override + Future open(MpvMedia media, {bool play = true}) async { + _checkDisposed(); + await _ensureInitialized(); + + // Show the video layer + await _methodChannel.invokeMethod('setVisible', {'visible': true}); + _isVisible = true; + + await command(['loadfile', media.uri, 'replace']); + if (!play) { + await setProperty('pause', 'yes'); + } + } + + @override + Future play() async { + _checkDisposed(); + await setProperty('pause', 'no'); + } + + @override + Future pause() async { + _checkDisposed(); + await setProperty('pause', 'yes'); + } + + @override + Future playOrPause() async { + _checkDisposed(); + if (_state.playing) { + await pause(); + } else { + await play(); + } + } + + @override + Future stop() async { + _checkDisposed(); + await command(['stop']); + await _methodChannel.invokeMethod('setVisible', {'visible': false}); + _isVisible = false; + } + + @override + Future seek(Duration position) async { + _checkDisposed(); + await command([ + 'seek', + (position.inMilliseconds / 1000.0).toString(), + 'absolute', + ]); + } + + // ============================================ + // Track Selection + // ============================================ + + @override + Future selectAudioTrack(MpvAudioTrack track) async { + _checkDisposed(); + await setProperty('aid', track.id); + } + + @override + Future selectSubtitleTrack(MpvSubtitleTrack track) async { + _checkDisposed(); + await setProperty('sid', track.id); + } + + @override + Future addSubtitleTrack({ + required String uri, + String? title, + String? language, + bool select = false, + }) async { + _checkDisposed(); + final args = ['sub-add', uri, select ? 'select' : 'auto']; + if (title != null) args.add('title=$title'); + if (language != null) args.add('lang=$language'); + await command(args); + } + + // ============================================ + // Volume and Rate + // ============================================ + + @override + Future setVolume(double volume) async { + _checkDisposed(); + await setProperty('volume', volume.toString()); + } + + @override + Future setRate(double rate) async { + _checkDisposed(); + await setProperty('speed', rate.toString()); + } + + @override + Future setAudioDevice(MpvAudioDevice device) async { + _checkDisposed(); + await setProperty('audio-device', device.name); + } + + // ============================================ + // MPV Properties + // ============================================ + + @override + Future setProperty(String name, String value) async { + _checkDisposed(); + await _methodChannel.invokeMethod('setProperty', { + 'name': name, + 'value': value, + }); + } + + @override + Future getProperty(String name) async { + _checkDisposed(); + return await _methodChannel.invokeMethod('getProperty', { + 'name': name, + }); + } + + @override + Future command(List args) async { + _checkDisposed(); + await _methodChannel.invokeMethod('command', {'args': args}); + } + + // ============================================ + // Passthrough + // ============================================ + + @override + Future setAudioPassthrough(bool enabled) async { + _checkDisposed(); + if (enabled) { + await setProperty('audio-spdif', 'ac3,eac3,dts,dts-hd,truehd'); + await setProperty('audio-exclusive', 'yes'); + } else { + await setProperty('audio-spdif', ''); + await setProperty('audio-exclusive', 'no'); + } + } + + // ============================================ + // Visibility + // ============================================ + + @override + Future setVisible(bool visible) async { + _checkDisposed(); + + try { + await _methodChannel.invokeMethod('setVisible', {'visible': visible}); + _isVisible = visible; + return true; + } catch (e) { + _errorController.add('Failed to set visibility: $e'); + return false; + } + } + + // ============================================ + // Lifecycle + // ============================================ + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + + await _eventSubscription?.cancel(); + await _methodChannel.invokeMethod('dispose'); + + await _playingController.close(); + await _completedController.close(); + await _bufferingController.close(); + await _positionController.close(); + await _durationController.close(); + await _bufferController.close(); + await _volumeController.close(); + await _rateController.close(); + await _tracksController.close(); + await _trackController.close(); + await _logController.close(); + await _errorController.close(); + await _audioDeviceController.close(); + await _audioDevicesController.close(); + } +} diff --git a/lib/mpv/src/video/mpv_video.dart b/lib/mpv/src/video/mpv_video.dart index a8dcde60..c97ad346 100644 --- a/lib/mpv/src/video/mpv_video.dart +++ b/lib/mpv/src/video/mpv_video.dart @@ -47,11 +47,8 @@ class MpvVideo extends StatefulWidget { class _MpvVideoState extends State { @override Widget build(BuildContext context) { - // macOS uses transparent background so native Metal layer shows through - final bgColor = Platform.isMacOS ? Colors.transparent : widget.backgroundColor; - return Container( - color: bgColor, + color: Colors.transparent, child: Stack( fit: StackFit.expand, children: [ @@ -66,41 +63,6 @@ class _MpvVideoState extends State { } Widget _buildVideoSurface() { - // macOS: Video renders behind Flutter via native Metal layer - // Just show transparent container so native layer shows through - if (Platform.isMacOS) { - return const SizedBox.expand(); - } - - // When textureId is available, use Flutter's Texture widget - final textureId = widget.player.textureId; - if (textureId != null) { - return FittedBox( - fit: widget.fit, - child: Texture(textureId: textureId), - ); - } - - // Placeholder when video is not ready - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.play_circle_outline, - size: 64, - color: Colors.white.withValues(alpha: 0.3), - ), - const SizedBox(height: 16), - Text( - 'MPV Video', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.3), - fontSize: 14, - ), - ), - ], - ), - ); + return const SizedBox.expand(); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 63ab65e6..6b14df58 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -225,6 +225,7 @@ class VideoPlayerScreenState extends State player = MpvPlayer(); // Configure player properties + await player!.setProperty('target-colorspace-hint', 'yes'); // Enable HDR passthrough await player!.setProperty('sub-ass', 'yes'); // Enable libass await player!.setProperty('sub-fonts-dir', 'assets'); await player!.setProperty('sub-font', 'Go Noto Current-Regular'); @@ -910,7 +911,7 @@ class VideoPlayerScreenState extends State }, child: Scaffold( // Use transparent background on macOS when native video layer is active - backgroundColor: Platform.isMacOS ? Colors.transparent : Colors.black, + backgroundColor: Colors.transparent, body: GestureDetector( behavior: HitTestBehavior .translucent, // Allow taps to pass through to controls