From 666b59c293e463a5a66cee5ad69e7f1769a3340d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:30:11 +0100 Subject: [PATCH] refactor(native-player): share player helpers --- android/app/src/main/AndroidManifest.xml | 6 +- .../kotlin/com/edde746/plezy/MainActivity.kt | 6 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 4 +- .../plezy/exoplayer/ExoPlayerPlugin.kt | 5 +- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 8 +- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 4 +- .../edde746/plezy/shared/PlayerDelegate.kt | 6 + .../plezy/watchnext/WatchNextPlugin.kt | 2 +- ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 100 ++------------- lib/providers/theme_provider.dart | 2 +- lib/services/external_player_service.dart | 2 +- lib/services/pip_service.dart | 2 +- lib/services/watch_next_service.dart | 2 +- linux/runner/CMakeLists.txt | 1 + linux/runner/mpv/mpv_player.cc | 43 +------ macos/Runner.xcodeproj/project.pbxproj | 6 +- macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 119 ++---------------- .../apple}/MpvPlayer/MpvPlayerCoreBase.swift | 0 .../MpvPlayer/MpvPlayerPluginShared.swift | 105 ++++++++++++++++ shared/cpp/sanitize_utf8.h | 48 +++++++ windows/runner/CMakeLists.txt | 1 + windows/runner/mpv/mpv_player.cpp | 43 +------ 23 files changed, 208 insertions(+), 313 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/PlayerDelegate.kt rename {apple/Shared => shared/apple}/MpvPlayer/MpvPlayerCoreBase.swift (100%) create mode 100644 shared/apple/MpvPlayer/MpvPlayerPluginShared.swift create mode 100644 shared/cpp/sanitize_utf8.h 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 {