refactor(native-player): share player helpers
This commit is contained in:
@@ -9,11 +9,7 @@
|
||||
<!-- Camera permission for QR code scanning (companion remote pairing) -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<!-- PIP 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 -->
|
||||
<!-- 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"/>
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val PIP_CHANNEL = "app.plezy/pip"
|
||||
private val EXTERNAL_PLAYER_CHANNEL = "app.plezy/external_player"
|
||||
private val THEME_CHANNEL = "app.plezy/theme"
|
||||
private val PIP_CHANNEL = "com.plezy/pip"
|
||||
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
|
||||
private val THEME_CHANNEL = "com.plezy/theme"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
|
||||
// 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.widget.AssSubtitleView
|
||||
|
||||
interface ExoPlayerDelegate {
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
|
||||
|
||||
/**
|
||||
* Called when ExoPlayer encounters a format it cannot play.
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
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.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
@@ -15,7 +14,7 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate, MpvPlayerDelegate {
|
||||
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerPlugin"
|
||||
@@ -34,7 +33,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var configuredBufferSizeBytes: Int? = null
|
||||
private var configuredTunnelingEnabled: Boolean = true
|
||||
|
||||
private var debugLoggingEnabled: Boolean = false
|
||||
private val pendingMpvProperties = mutableListOf<Pair<String, String>>()
|
||||
@@ -161,7 +159,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
val bufferSizeBytes = call.argument<Int>("bufferSizeBytes")
|
||||
val tunnelingEnabled = call.argument<Boolean>("tunnelingEnabled") ?: true
|
||||
configuredBufferSizeBytes = bufferSizeBytes
|
||||
configuredTunnelingEnabled = tunnelingEnabled
|
||||
|
||||
currentActivity.runOnUiThread {
|
||||
try {
|
||||
|
||||
@@ -16,15 +16,11 @@ import android.view.ViewTreeObserver
|
||||
import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FlutterOverlayHelper
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import dev.jdtech.mpv.MPVLib
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
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) :
|
||||
SurfaceHolder.Callback,
|
||||
MPVLib.EventObserver,
|
||||
@@ -59,7 +55,7 @@ class MpvPlayerCore(private val activity: Activity) :
|
||||
@Volatile private var disposing: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var lastSurfaceSize: String? = null
|
||||
var delegate: MpvPlayerDelegate? = null
|
||||
var delegate: PlayerDelegate? = null
|
||||
var isInitialized: Boolean = false
|
||||
private set
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler, ActivityAware, MpvPlayerDelegate {
|
||||
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerPlugin"
|
||||
@@ -281,7 +281,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
} ?: result.error("NO_PLAYER", "Player not initialized", null)
|
||||
}
|
||||
|
||||
// MpvPlayerDelegate
|
||||
// PlayerDelegate
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
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 {
|
||||
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
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.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 */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -118,6 +120,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
|
||||
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
|
||||
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
|
||||
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
|
||||
92F969587D0E464D999910F4 /* MpvPipController.swift */,
|
||||
@@ -420,6 +423,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
|
||||
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
|
||||
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
|
||||
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
|
||||
|
||||
@@ -3,14 +3,19 @@ import Flutter
|
||||
import AVKit
|
||||
|
||||
/// 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
|
||||
|
||||
private var playerCore: MpvPlayerCore?
|
||||
private var eventSink: FlutterEventSink?
|
||||
var eventSink: FlutterEventSink?
|
||||
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
|
||||
private var pipController: MpvPipController?
|
||||
@@ -33,7 +38,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "app.plezy/pip",
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
|
||||
@@ -252,7 +257,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
pipTimebaseSyncTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Method Handlers
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -320,91 +325,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
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
|
||||
|
||||
private func findKeyWindow() -> UIWindow? {
|
||||
|
||||
@@ -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 {
|
||||
if (_themeMode != mode) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import '../i18n/strings.g.dart';
|
||||
import 'plex_client.dart';
|
||||
import 'settings_service.dart';
|
||||
|
||||
const _externalPlayerChannel = MethodChannel('app.plezy/external_player');
|
||||
const _externalPlayerChannel = MethodChannel('com.plezy/external_player');
|
||||
|
||||
class ExternalPlayerService {
|
||||
/// Launch an external player with either a pre-resolved [videoUrl] (e.g. local
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
|
||||
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.
|
||||
static bool get _isAvailable => Platform.isAndroid || Platform.isIOS || Platform.isMacOS;
|
||||
|
||||
@@ -9,7 +9,7 @@ import 'settings_service.dart' show EpisodePosterMode;
|
||||
|
||||
/// Service for syncing Plex "On Deck" content to Android TV's Watch Next row.
|
||||
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();
|
||||
factory WatchNextService() => _instance;
|
||||
|
||||
@@ -43,3 +43,4 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::EPOXY)
|
||||
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}/../shared/cpp")
|
||||
|
||||
@@ -11,49 +11,8 @@
|
||||
#include <gdk/gdkwayland.h>
|
||||
#endif
|
||||
#include <clocale>
|
||||
#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 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;
|
||||
}
|
||||
#include "sanitize_utf8.h"
|
||||
|
||||
// Flutter on Linux uses EGL (OpenGL ES) for both X11 and Wayland.
|
||||
static void* get_opengl_proc_address(void* ctx, const char* name) {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */; };
|
||||
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.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 */; };
|
||||
6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -221,6 +223,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */,
|
||||
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */,
|
||||
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
|
||||
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
|
||||
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
|
||||
@@ -473,6 +476,7 @@
|
||||
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
|
||||
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
|
||||
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */,
|
||||
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */,
|
||||
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
|
||||
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
|
||||
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
|
||||
|
||||
@@ -2,14 +2,19 @@ import Cocoa
|
||||
import FlutterMacOS
|
||||
|
||||
/// 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
|
||||
|
||||
private var playerCore: MpvPlayerCore?
|
||||
private var eventSink: FlutterEventSink?
|
||||
var eventSink: FlutterEventSink?
|
||||
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
|
||||
private var pipController: MpvPipController?
|
||||
@@ -33,7 +38,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
)
|
||||
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "app.plezy/pip",
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
|
||||
@@ -198,7 +203,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
pip.stopPip()
|
||||
}
|
||||
|
||||
// MARK: - Method Handlers
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@@ -277,110 +282,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPlayerD
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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_
|
||||
@@ -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_CURRENT_SOURCE_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.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
@@ -1,47 +1,6 @@
|
||||
#include "mpv_player.h"
|
||||
|
||||
#include <cstring>
|
||||
#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;
|
||||
}
|
||||
#include "sanitize_utf8.h"
|
||||
|
||||
namespace mpv {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user