ios
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'mpv_player_native.dart';
|
||||
|
||||
/// iOS implementation of MpvPlayer.
|
||||
/// Inherits all functionality from MpvPlayerNative.
|
||||
class MpvPlayerIOS extends MpvPlayerNative {}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user