This commit is contained in:
edde746
2025-12-02 15:46:19 +01:00
parent d9cdde66d8
commit 1daded6ec6
13 changed files with 1244 additions and 571 deletions
-24
View File
@@ -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
+40
View File
@@ -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 = "<group>"; };
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 = "<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>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; 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>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
6A8A46242EDB370C0057B88C /* MpvPlayer */ = {
isa = PBXGroup;
children = (
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
);
path = MpvPlayer;
sourceTree = "<group>";
};
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 */;
}
@@ -0,0 +1,14 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a"
}
}
],
"version" : 3
}
@@ -0,0 +1,14 @@
{
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "a8ae0d2f08df6188c662b7670bddb97131db8c5a"
}
}
],
"version" : 3
}
+5
View File
@@ -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()
+411
View File
@@ -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<MpvPlayerCore>.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<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_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<CChar>?.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..<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({ String(cString: $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() {
NotificationCenter.default.removeObserver(self)
if let mpv = mpv {
mpv_terminate_destroy(mpv)
self.mpv = nil
}
metalLayer?.removeFromSuperlayer()
metalLayer = nil
isInitialized = false
print("[MpvPlayerCore] Disposed")
}
deinit {
dispose()
}
}
+235
View File
@@ -0,0 +1,235 @@
import UIKit
import Flutter
/// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate {
// MARK: - Properties
private var playerCore: MpvPlayerCore?
private var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar?
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
// Method channel for commands
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger()
)
// Event channel for state updates
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger()
)
let instance = MpvPlayerPlugin()
instance.registrar = registrar
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
print("[MpvPlayerPlugin] Registered with Flutter")
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> 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
}
}
+6 -1
View File
@@ -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();
}
}
+5
View File
@@ -0,0 +1,5 @@
import 'mpv_player_native.dart';
/// iOS implementation of MpvPlayer.
/// Inherits all functionality from MpvPlayerNative.
class MpvPlayerIOS extends MpvPlayerNative {}
+4 -505
View File
@@ -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<bool>.broadcast();
final _completedController = StreamController<bool>.broadcast();
final _bufferingController = StreamController<bool>.broadcast();
final _positionController = StreamController<Duration>.broadcast();
final _durationController = StreamController<Duration>.broadcast();
final _bufferController = StreamController<Duration>.broadcast();
final _volumeController = StreamController<double>.broadcast();
final _rateController = StreamController<double>.broadcast();
final _tracksController = StreamController<MpvTracks>.broadcast();
final _trackController = StreamController<MpvTrackSelection>.broadcast();
final _logController = StreamController<MpvLog>.broadcast();
final _errorController = StreamController<String>.broadcast();
final _audioDeviceController = StreamController<MpvAudioDevice>.broadcast();
final _audioDevicesController =
StreamController<List<MpvAudioDevice>>.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 = <MpvAudioTrack>[];
final subtitleTracks = <MpvSubtitleTrack>[];
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<MpvAudioTrack?>().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<MpvSubtitleTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
track: _state.track.copyWith(subtitle: selectedTrack),
);
_trackController.add(_state.track);
}
Future<void> _ensureInitialized() async {
if (_initialized) return;
try {
final result = await _methodChannel.invokeMethod<bool>('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<void> _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<void> 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<void> play() async {
_checkDisposed();
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
_checkDisposed();
await setProperty('pause', 'yes');
}
@override
Future<void> playOrPause() async {
_checkDisposed();
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> stop() async {
_checkDisposed();
await command(['stop']);
await _methodChannel.invokeMethod('setVisible', {'visible': false});
_isVisible = false;
}
@override
Future<void> seek(Duration position) async {
_checkDisposed();
await command([
'seek',
(position.inMilliseconds / 1000.0).toString(),
'absolute',
]);
}
// ============================================
// Track Selection
// ============================================
@override
Future<void> selectAudioTrack(MpvAudioTrack track) async {
_checkDisposed();
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(MpvSubtitleTrack track) async {
_checkDisposed();
await setProperty('sid', track.id);
}
@override
Future<void> 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<void> setVolume(double volume) async {
_checkDisposed();
await setProperty('volume', volume.toString());
}
@override
Future<void> setRate(double rate) async {
_checkDisposed();
await setProperty('speed', rate.toString());
}
@override
Future<void> setAudioDevice(MpvAudioDevice device) async {
_checkDisposed();
await setProperty('audio-device', device.name);
}
// ============================================
// MPV Properties
// ============================================
@override
Future<void> setProperty(String name, String value) async {
_checkDisposed();
await _methodChannel.invokeMethod('setProperty', {
'name': name,
'value': value,
});
}
@override
Future<String?> getProperty(String name) async {
_checkDisposed();
return await _methodChannel.invokeMethod<String>('getProperty', {
'name': name,
});
}
@override
Future<void> command(List<String> args) async {
_checkDisposed();
await _methodChannel.invokeMethod('command', {'args': args});
}
// ============================================
// Passthrough
// ============================================
@override
Future<void> 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<bool> 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<void> 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 {}
+506
View File
@@ -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<bool>.broadcast();
final _completedController = StreamController<bool>.broadcast();
final _bufferingController = StreamController<bool>.broadcast();
final _positionController = StreamController<Duration>.broadcast();
final _durationController = StreamController<Duration>.broadcast();
final _bufferController = StreamController<Duration>.broadcast();
final _volumeController = StreamController<double>.broadcast();
final _rateController = StreamController<double>.broadcast();
final _tracksController = StreamController<MpvTracks>.broadcast();
final _trackController = StreamController<MpvTrackSelection>.broadcast();
final _logController = StreamController<MpvLog>.broadcast();
final _errorController = StreamController<String>.broadcast();
final _audioDeviceController = StreamController<MpvAudioDevice>.broadcast();
final _audioDevicesController =
StreamController<List<MpvAudioDevice>>.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 = <MpvAudioTrack>[];
final subtitleTracks = <MpvSubtitleTrack>[];
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<MpvAudioTrack?>().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<MpvSubtitleTrack?>().firstWhere(
(t) => t?.id == id,
orElse: () => null,
);
}
_state = _state.copyWith(
track: _state.track.copyWith(subtitle: selectedTrack),
);
_trackController.add(_state.track);
}
Future<void> _ensureInitialized() async {
if (_initialized) return;
try {
final result = await _methodChannel.invokeMethod<bool>('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<void> _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<void> 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<void> play() async {
_checkDisposed();
await setProperty('pause', 'no');
}
@override
Future<void> pause() async {
_checkDisposed();
await setProperty('pause', 'yes');
}
@override
Future<void> playOrPause() async {
_checkDisposed();
if (_state.playing) {
await pause();
} else {
await play();
}
}
@override
Future<void> stop() async {
_checkDisposed();
await command(['stop']);
await _methodChannel.invokeMethod('setVisible', {'visible': false});
_isVisible = false;
}
@override
Future<void> seek(Duration position) async {
_checkDisposed();
await command([
'seek',
(position.inMilliseconds / 1000.0).toString(),
'absolute',
]);
}
// ============================================
// Track Selection
// ============================================
@override
Future<void> selectAudioTrack(MpvAudioTrack track) async {
_checkDisposed();
await setProperty('aid', track.id);
}
@override
Future<void> selectSubtitleTrack(MpvSubtitleTrack track) async {
_checkDisposed();
await setProperty('sid', track.id);
}
@override
Future<void> 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<void> setVolume(double volume) async {
_checkDisposed();
await setProperty('volume', volume.toString());
}
@override
Future<void> setRate(double rate) async {
_checkDisposed();
await setProperty('speed', rate.toString());
}
@override
Future<void> setAudioDevice(MpvAudioDevice device) async {
_checkDisposed();
await setProperty('audio-device', device.name);
}
// ============================================
// MPV Properties
// ============================================
@override
Future<void> setProperty(String name, String value) async {
_checkDisposed();
await _methodChannel.invokeMethod('setProperty', {
'name': name,
'value': value,
});
}
@override
Future<String?> getProperty(String name) async {
_checkDisposed();
return await _methodChannel.invokeMethod<String>('getProperty', {
'name': name,
});
}
@override
Future<void> command(List<String> args) async {
_checkDisposed();
await _methodChannel.invokeMethod('command', {'args': args});
}
// ============================================
// Passthrough
// ============================================
@override
Future<void> 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<bool> 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<void> 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();
}
}
+2 -40
View File
@@ -47,11 +47,8 @@ class MpvVideo extends StatefulWidget {
class _MpvVideoState extends State<MpvVideo> {
@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<MpvVideo> {
}
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();
}
}
+2 -1
View File
@@ -225,6 +225,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
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<VideoPlayerScreen>
},
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