diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 739444dd..a26ccc5a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,11 +9,7 @@
-
-
-
-
-
+
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
index be0de8ab..e85f0c24 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt
@@ -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
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
index dab961af..eeea5fe9 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt
@@ -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?)
+interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
/**
* Called when ExoPlayer encounters a format it cannot play.
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
index a3ef7863..44cbff88 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt
@@ -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()
private var configuredBufferSizeBytes: Int? = null
- private var configuredTunnelingEnabled: Boolean = true
private var debugLoggingEnabled: Boolean = false
private val pendingMpvProperties = mutableListOf>()
@@ -161,7 +159,6 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
val bufferSizeBytes = call.argument("bufferSizeBytes")
val tunnelingEnabled = call.argument("tunnelingEnabled") ?: true
configuredBufferSizeBytes = bufferSizeBytes
- configuredTunnelingEnabled = tunnelingEnabled
currentActivity.runOnUiThread {
try {
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
index 7a8a6ac4..cfde1df7 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt
@@ -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?)
-}
-
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
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
index 935e8452..65e25e44 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt
@@ -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
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt
new file mode 100644
index 00000000..a847eb52
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt
@@ -0,0 +1,6 @@
+package com.edde746.plezy.shared
+
+interface PlayerDelegate {
+ fun onPropertyChange(name: String, value: Any?)
+ fun onEvent(name: String, data: Map?)
+}
diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
index 5de420f1..0d758e82 100644
--- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
+++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt
@@ -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
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 4d64d79b..9077940f 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; };
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; };
- 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 = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
@@ -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 */,
diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
index 8962bf08..9a3a1522 100644
--- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
+++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift
@@ -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? {
diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart
index 98743ea8..ede9d0f9 100644
--- a/lib/providers/theme_provider.dart
+++ b/lib/providers/theme_provider.dart
@@ -66,7 +66,7 @@ class ThemeProvider extends ChangeNotifier {
}
}
- static const _themeChannel = MethodChannel('app.plezy/theme');
+ static const _themeChannel = MethodChannel('com.plezy/theme');
Future setThemeMode(settings.ThemeMode mode) async {
if (_themeMode != mode) {
diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart
index 909cde7d..49231525 100644
--- a/lib/services/external_player_service.dart
+++ b/lib/services/external_player_service.dart
@@ -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
diff --git a/lib/services/pip_service.dart b/lib/services/pip_service.dart
index 125d3faf..172fce9e 100644
--- a/lib/services/pip_service.dart
+++ b/lib/services/pip_service.dart
@@ -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;
diff --git a/lib/services/watch_next_service.dart b/lib/services/watch_next_service.dart
index 74f7b77c..4c17310a 100644
--- a/lib/services/watch_next_service.dart
+++ b/lib/services/watch_next_service.dart
@@ -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;
diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt
index 80ab60d4..5e96f00a 100644
--- a/linux/runner/CMakeLists.txt
+++ b/linux/runner/CMakeLists.txt
@@ -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")
diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc
index 029c95c0..88c97b7f 100644
--- a/linux/runner/mpv/mpv_player.cc
+++ b/linux/runner/mpv/mpv_player.cc
@@ -11,49 +11,8 @@
#include
#endif
#include
-#include
-#include
-#include
-// 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) {
diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj
index 869df956..f864b8c5 100644
--- a/macos/Runner.xcodeproj/project.pbxproj
+++ b/macos/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; };
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; };
- 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 = ""; };
6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; };
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = ""; };
@@ -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 */,
diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift
index 5a8ff2e5..599f9d04 100644
--- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift
+++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift
@@ -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)? {
diff --git a/apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift
similarity index 100%
rename from apple/Shared/MpvPlayer/MpvPlayerCoreBase.swift
rename to shared/apple/MpvPlayer/MpvPlayerCoreBase.swift
diff --git a/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift
new file mode 100644
index 00000000..3d396d72
--- /dev/null
+++ b/shared/apple/MpvPlayer/MpvPlayerPluginShared.swift
@@ -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)
+ }
+}
diff --git a/shared/cpp/sanitize_utf8.h b/shared/cpp/sanitize_utf8.h
new file mode 100644
index 00000000..d0f508d1
--- /dev/null
+++ b/shared/cpp/sanitize_utf8.h
@@ -0,0 +1,48 @@
+#ifndef SANITIZE_UTF8_H_
+#define SANITIZE_UTF8_H_
+
+#include
+#include
+#include
+
+// 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_
diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt
index a398a7e4..9b14c21d 100644
--- a/windows/runner/CMakeLists.txt
+++ b/windows/runner/CMakeLists.txt
@@ -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)
diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp
index 0dd82041..32dfc334 100644
--- a/windows/runner/mpv/mpv_player.cpp
+++ b/windows/runner/mpv/mpv_player.cpp
@@ -1,47 +1,6 @@
#include "mpv_player.h"
-#include
-#include
-
-// 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 {