refactor(native-player): share player helpers

This commit is contained in:
edde746
2026-03-11 01:30:11 +01:00
parent 7543a8c55b
commit 666b59c293
23 changed files with 208 additions and 313 deletions
+1 -5
View File
@@ -9,11 +9,7 @@
<!-- Camera permission for QR code scanning (companion remote pairing) --> <!-- Camera permission for QR code scanning (companion remote pairing) -->
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<!-- PIP Permissions --> <!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<!-- Media session permissions for OS media controls -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
@@ -27,9 +27,9 @@ import java.io.File
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val PIP_CHANNEL = "app.plezy/pip" private val PIP_CHANNEL = "com.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "app.plezy/external_player" private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
private val THEME_CHANNEL = "app.plezy/theme" private val THEME_CHANNEL = "com.plezy/theme"
private var watchNextPlugin: WatchNextPlugin? = null private var watchNextPlugin: WatchNextPlugin? = null
// Auto PiP state // Auto PiP state
@@ -55,9 +55,7 @@ import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory
import io.github.peerless2012.ass.media.type.AssRenderType import io.github.peerless2012.ass.media.type.AssRenderType
import io.github.peerless2012.ass.media.widget.AssSubtitleView import io.github.peerless2012.ass.media.widget.AssSubtitleView
interface ExoPlayerDelegate { interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
/** /**
* Called when ExoPlayer encounters a format it cannot play. * Called when ExoPlayer encounters a format it cannot play.
@@ -6,7 +6,6 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.edde746.plezy.mpv.MpvPlayerCore import com.edde746.plezy.mpv.MpvPlayerCore
import com.edde746.plezy.mpv.MpvPlayerDelegate
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -15,7 +14,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate, MpvPlayerDelegate { EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate {
companion object { companion object {
private const val TAG = "ExoPlayerPlugin" private const val TAG = "ExoPlayerPlugin"
@@ -34,7 +33,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private var activityBinding: ActivityPluginBinding? = null private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>() private val nameToId = mutableMapOf<String, Int>()
private var configuredBufferSizeBytes: Int? = null private var configuredBufferSizeBytes: Int? = null
private var configuredTunnelingEnabled: Boolean = true
private var debugLoggingEnabled: Boolean = false private var debugLoggingEnabled: Boolean = false
private val pendingMpvProperties = mutableListOf<Pair<String, String>>() private val pendingMpvProperties = mutableListOf<Pair<String, String>>()
@@ -161,7 +159,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes") val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
configuredBufferSizeBytes = bufferSizeBytes configuredBufferSizeBytes = bufferSizeBytes
configuredTunnelingEnabled = tunnelingEnabled
currentActivity.runOnUiThread { currentActivity.runOnUiThread {
try { try {
@@ -16,15 +16,11 @@ import android.view.ViewTreeObserver
import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate
import dev.jdtech.mpv.MPVLib import dev.jdtech.mpv.MPVLib
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.Executors import java.util.concurrent.Executors
interface MpvPlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
class MpvPlayerCore(private val activity: Activity) : class MpvPlayerCore(private val activity: Activity) :
SurfaceHolder.Callback, SurfaceHolder.Callback,
MPVLib.EventObserver, MPVLib.EventObserver,
@@ -59,7 +55,7 @@ class MpvPlayerCore(private val activity: Activity) :
@Volatile private var disposing: Boolean = false @Volatile private var disposing: Boolean = false
private var pendingSurface: Surface? = null private var pendingSurface: Surface? = null
private var lastSurfaceSize: String? = null private var lastSurfaceSize: String? = null
var delegate: MpvPlayerDelegate? = null var delegate: PlayerDelegate? = null
var isInitialized: Boolean = false var isInitialized: Boolean = false
private set private set
@@ -11,7 +11,7 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, ActivityAware, MpvPlayerDelegate { EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
companion object { companion object {
private const val TAG = "MpvPlayerPlugin" private const val TAG = "MpvPlayerPlugin"
@@ -281,7 +281,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
} ?: result.error("NO_PLAYER", "Player not initialized", null) } ?: result.error("NO_PLAYER", "Player not initialized", null)
} }
// MpvPlayerDelegate // PlayerDelegate
override fun onPropertyChange(name: String, value: Any?) { override fun onPropertyChange(name: String, value: Any?) {
val propId = nameToId[name] ?: return val propId = nameToId[name] ?: return
@@ -0,0 +1,6 @@
package com.edde746.plezy.shared
interface PlayerDelegate {
fun onPropertyChange(name: String, value: Any?)
fun onEvent(name: String, data: Map<String, Any>?)
}
@@ -17,7 +17,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
companion object { companion object {
private const val TAG = "WatchNextPlugin" private const val TAG = "WatchNextPlugin"
private const val METHOD_CHANNEL = "app.plezy/watch_next" private const val METHOD_CHANNEL = "com.plezy/watch_next"
private var pendingDeepLink: String? = null private var pendingDeepLink: String? = null
+5 -1
View File
@@ -16,6 +16,7 @@
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; }; 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; }; 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; }; B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; }; 92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -59,7 +60,8 @@
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; }; 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; }; 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; }; 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; }; 92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -118,6 +120,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */, B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */, 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */, 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
92F969587D0E464D999910F4 /* MpvPipController.swift */, 92F969587D0E464D999910F4 /* MpvPipController.swift */,
@@ -420,6 +423,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */, B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */, 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */, 6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */, 92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
+10 -90
View File
@@ -3,14 +3,19 @@ import Flutter
import AVKit import AVKit
/// Flutter plugin that bridges MPV player to Dart via method and event channels /// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate { class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
// MARK: - Properties // MARK: - Properties
private var playerCore: MpvPlayerCore? private var playerCore: MpvPlayerCore?
private var eventSink: FlutterEventSink? var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar? private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:] var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP // PiP
private var pipController: MpvPipController? private var pipController: MpvPipController?
@@ -33,7 +38,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
let pipChannel = FlutterMethodChannel( let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip", name: "com.plezy/pip",
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
@@ -252,7 +257,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
pipTimebaseSyncTimer = nil pipTimebaseSyncTimer = nil
} }
// MARK: - Method Handlers // MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) { private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@@ -320,91 +325,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
result(nil) 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
}
result(playerCore?.getProperty(name))
}
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,
let id = args["id"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument", details: nil))
return
}
nameToId[name] = id
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?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
result(nil)
case .failure(let error):
result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
}
} ?? 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)
if visible { self?.playerCore?.updateFrame() }
result(nil)
}
}
private func handleUpdateFrame(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.updateFrame()
result(nil)
}
}
private func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let level = args["level"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
return
}
playerCore?.setLogLevel(level)
result(nil)
}
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
eventSink([propId, value as Any])
}
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 // MARK: - Helpers
private func findKeyWindow() -> UIWindow? { private func findKeyWindow() -> UIWindow? {
+1 -1
View File
@@ -66,7 +66,7 @@ class ThemeProvider extends ChangeNotifier {
} }
} }
static const _themeChannel = MethodChannel('app.plezy/theme'); static const _themeChannel = MethodChannel('com.plezy/theme');
Future<void> setThemeMode(settings.ThemeMode mode) async { Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode != mode) { if (_themeMode != mode) {
+1 -1
View File
@@ -11,7 +11,7 @@ import '../i18n/strings.g.dart';
import 'plex_client.dart'; import 'plex_client.dart';
import 'settings_service.dart'; import 'settings_service.dart';
const _externalPlayerChannel = MethodChannel('app.plezy/external_player'); const _externalPlayerChannel = MethodChannel('com.plezy/external_player');
class ExternalPlayerService { class ExternalPlayerService {
/// Launch an external player with either a pre-resolved [videoUrl] (e.g. local /// Launch an external player with either a pre-resolved [videoUrl] (e.g. local
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/i18n/strings.g.dart';
class PipService { class PipService {
static const MethodChannel _channel = MethodChannel('app.plezy/pip'); static const MethodChannel _channel = MethodChannel('com.plezy/pip');
/// PiP is only implemented natively on Android, iOS, and macOS. /// PiP is only implemented natively on Android, iOS, and macOS.
static bool get _isAvailable => Platform.isAndroid || Platform.isIOS || Platform.isMacOS; static bool get _isAvailable => Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
+1 -1
View File
@@ -9,7 +9,7 @@ import 'settings_service.dart' show EpisodePosterMode;
/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row. /// Service for syncing Plex "On Deck" content to Android TV's Watch Next row.
class WatchNextService { class WatchNextService {
static const MethodChannel _channel = MethodChannel('app.plezy/watch_next'); static const MethodChannel _channel = MethodChannel('com.plezy/watch_next');
static final WatchNextService _instance = WatchNextService._internal(); static final WatchNextService _instance = WatchNextService._internal();
factory WatchNextService() => _instance; factory WatchNextService() => _instance;
+1
View File
@@ -43,3 +43,4 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EPOXY)
target_link_libraries(${BINARY_NAME} PRIVATE simdutf) target_link_libraries(${BINARY_NAME} PRIVATE simdutf)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
+1 -42
View File
@@ -11,49 +11,8 @@
#include <gdk/gdkwayland.h> #include <gdk/gdkwayland.h>
#endif #endif
#include <clocale> #include <clocale>
#include <cstring>
#include <string>
#include <simdutf.h>
// Sanitize a C string that may contain invalid UTF-8 sequences. #include "sanitize_utf8.h"
// Uses simdutf for SIMD-accelerated validation (fast path for valid strings),
// then falls back to iterative replacement with U+FFFD on the rare invalid case.
// mpv does not guarantee UTF-8 for log messages, error strings, or
// system-encoded paths — sending these unsanitized through Flutter's
// StandardMessageCodec causes FormatException crashes.
static std::string SanitizeUtf8(const char* input) {
if (!input) return std::string();
size_t len = strlen(input);
if (len == 0) return std::string();
// Fast path: SIMD-accelerated validation — almost all strings pass this
if (simdutf::validate_utf8(input, len)) {
return std::string(input, len);
}
// Slow path: find each invalid position, copy valid prefix, insert U+FFFD,
// skip the bad byte, and repeat.
std::string result;
result.reserve(len);
size_t pos = 0;
while (pos < len) {
auto r = simdutf::validate_utf8_with_errors(input + pos, len - pos);
// Copy the valid prefix up to the error
if (r.count > 0) {
result.append(input + pos, r.count);
}
pos += r.count;
if (r.error == simdutf::error_code::SUCCESS) {
break; // remaining tail is valid
}
// Replace the invalid byte with U+FFFD and skip it
result.append("\xEF\xBF\xBD");
pos++;
}
return result;
}
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland. // Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
static void* get_opengl_proc_address(void* ctx, const char* name) { static void* get_opengl_proc_address(void* ctx, const char* name) {
+5 -1
View File
@@ -32,6 +32,7 @@
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; }; 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; }; 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; };
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; }; B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */; };
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; }; 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; };
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; };
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; }; 6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
@@ -89,7 +90,8 @@
6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; }; 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; }; 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = "<group>"; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = "<group>"; };
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; }; 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
@@ -221,6 +223,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */, B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */,
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */, 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */, 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */, 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
@@ -473,6 +476,7 @@
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */, B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */,
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
+10 -109
View File
@@ -2,14 +2,19 @@ import Cocoa
import FlutterMacOS import FlutterMacOS
/// Flutter plugin that bridges MPV player to Dart via method and event channels /// Flutter plugin that bridges MPV player to Dart via method and event channels
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerDelegate { class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
// MARK: - Properties // MARK: - Properties
private var playerCore: MpvPlayerCore? private var playerCore: MpvPlayerCore?
private var eventSink: FlutterEventSink? var eventSink: FlutterEventSink?
private weak var registrar: FlutterPluginRegistrar? private weak var registrar: FlutterPluginRegistrar?
private var nameToId: [String: Int] = [:] var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP // PiP
private var pipController: MpvPipController? private var pipController: MpvPipController?
@@ -33,7 +38,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
) )
let pipChannel = FlutterMethodChannel( let pipChannel = FlutterMethodChannel(
name: "app.plezy/pip", name: "com.plezy/pip",
binaryMessenger: registrar.messenger binaryMessenger: registrar.messenger
) )
@@ -198,7 +203,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
pip.stopPip() pip.stopPip()
} }
// MARK: - Method Handlers // MARK: - Platform-Specific Method Handlers
private func handleInitialize(result: @escaping FlutterResult) { private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
@@ -277,110 +282,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
result(nil) 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,
let id = args["id"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument", details: nil))
return
}
nameToId[name] = id
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
}
// Use async command to prevent UI blocking during network operations
playerCore?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
result(nil)
case .failure(let error):
result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
}
} ?? 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)
}
}
private func handleUpdateFrame(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.playerCore?.updateFrame()
result(nil)
}
}
private func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let playerCore = playerCore else {
result(FlutterError(code: "NOT_INITIALIZED", message: "Player not initialized", details: nil))
return
}
guard let args = call.arguments as? [String: Any],
let level = args["level"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
return
}
playerCore.setLogLevel(level)
result(nil)
}
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink else { return }
if let propId = nameToId[name] {
eventSink([propId, value as Any])
}
}
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 // MARK: - Helpers
private func findFlutterWindow() -> (NSWindow, NSView, NSView)? { private func findFlutterWindow() -> (NSWindow, NSView, NSView)? {
@@ -0,0 +1,105 @@
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#endif
/// Protocol for shared MpvPlayerPlugin method handlers across iOS and macOS.
/// Platform-specific methods (PiP, initialization, window finding) remain
/// in the per-platform MpvPlayerPlugin files.
protocol MpvPluginShared: AnyObject, MpvPlayerDelegate {
var coreBase: MpvPlayerCoreBase? { get }
var eventSink: FlutterEventSink? { get }
var nameToId: [String: Int] { get set }
func setPlayerVisible(_ visible: Bool)
func updatePlayerFrame()
}
extension MpvPluginShared {
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
}
result(coreBase?.getProperty(name))
}
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,
let id = args["id"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name', 'format', or 'id' argument", details: nil))
return
}
nameToId[name] = id
coreBase?.observeProperty(name, format: format)
result(nil)
}
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
}
coreBase?.commandAsync(commandArgs) { commandResult in
switch commandResult {
case .success:
result(nil)
case .failure(let error):
result(FlutterError(code: "COMMAND_FAILED", message: error.localizedDescription, details: nil))
}
} ?? result(nil)
}
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?.setPlayerVisible(visible)
if visible { self?.updatePlayerFrame() }
result(nil)
}
}
func handleUpdateFrame(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
self?.updatePlayerFrame()
result(nil)
}
}
func handleSetLogLevel(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let level = args["level"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'level'", details: nil))
return
}
coreBase?.setLogLevel(level)
result(nil)
}
// MARK: - MpvPlayerDelegate
func onPropertyChange(name: String, value: Any?) {
guard let eventSink = eventSink, let propId = nameToId[name] else { return }
eventSink([propId, value as Any])
}
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)
}
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef SANITIZE_UTF8_H_
#define SANITIZE_UTF8_H_
#include <cstring>
#include <string>
#include <simdutf.h>
// Sanitize a C string that may contain invalid UTF-8 sequences.
// Uses simdutf for SIMD-accelerated validation (fast path for valid strings),
// then falls back to iterative replacement with U+FFFD on the rare invalid case.
// mpv does not guarantee UTF-8 for log messages, error strings, or
// system-encoded paths — sending these unsanitized through Flutter's
// StandardMessageCodec causes FormatException crashes.
static inline std::string SanitizeUtf8(const char* input) {
if (!input) return std::string();
size_t len = strlen(input);
if (len == 0) return std::string();
// Fast path: SIMD-accelerated validation — almost all strings pass this
if (simdutf::validate_utf8(input, len)) {
return std::string(input, len);
}
// Slow path: find each invalid position, copy valid prefix, insert U+FFFD,
// skip the bad byte, and repeat.
std::string result;
result.reserve(len);
size_t pos = 0;
while (pos < len) {
auto r = simdutf::validate_utf8_with_errors(input + pos, len - pos);
// Copy the valid prefix up to the error
if (r.count > 0) {
result.append(input + pos, r.count);
}
pos += r.count;
if (r.error == simdutf::error_code::SUCCESS) {
break; // remaining tail is valid
}
// Replace the invalid byte with U+FFFD and skip it
result.append("\xEF\xBF\xBD");
pos++;
}
return result;
}
#endif // SANITIZE_UTF8_H_
+1
View File
@@ -51,6 +51,7 @@ target_link_libraries(${BINARY_NAME} PRIVATE simdutf)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_include_directories(${BINARY_NAME} PRIVATE "${MPV_INCLUDE_DIR}") target_include_directories(${BINARY_NAME} PRIVATE "${MPV_INCLUDE_DIR}")
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}/../shared/cpp")
# Run the Flutter tool portions of the build. This must not be removed. # Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble) add_dependencies(${BINARY_NAME} flutter_assemble)
+1 -42
View File
@@ -1,47 +1,6 @@
#include "mpv_player.h" #include "mpv_player.h"
#include <cstring> #include "sanitize_utf8.h"
#include <simdutf.h>
// Sanitize a C string that may contain invalid UTF-8 sequences.
// Uses simdutf for SIMD-accelerated validation (fast path for valid strings),
// then falls back to iterative replacement with U+FFFD on the rare invalid case.
// mpv does not guarantee UTF-8 for log messages, error strings, or
// system-encoded paths — sending these unsanitized through Flutter's
// StandardMessageCodec causes FormatException crashes.
static std::string SanitizeUtf8(const char* input) {
if (!input) return std::string();
size_t len = strlen(input);
if (len == 0) return std::string();
// Fast path: SIMD-accelerated validation — almost all strings pass this
if (simdutf::validate_utf8(input, len)) {
return std::string(input, len);
}
// Slow path: find each invalid position, copy valid prefix, insert U+FFFD,
// skip the bad byte, and repeat.
std::string result;
result.reserve(len);
size_t pos = 0;
while (pos < len) {
auto r = simdutf::validate_utf8_with_errors(input + pos, len - pos);
// Copy the valid prefix up to the error
if (r.count > 0) {
result.append(input + pos, r.count);
}
pos += r.count;
if (r.error == simdutf::error_code::SUCCESS) {
break; // remaining tail is valid
}
// Replace the invalid byte with U+FFFD and skip it
result.append("\xEF\xBF\xBD");
pos++;
}
return result;
}
namespace mpv { namespace mpv {