feat: exoplayer
This commit is contained in:
@@ -57,6 +57,13 @@ android {
|
||||
// If key.properties doesn't exist, it will use debug signing for CI builds
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Resolve conflict between libass-android and libmpv native libraries
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
@@ -65,4 +72,16 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation("dev.jdtech.mpv:libmpv:0.5.1")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||
implementation("androidx.media3:media3-ui:1.5.1")
|
||||
implementation("androidx.media3:media3-common:1.5.1")
|
||||
implementation("androidx.media3:media3-session:1.5.1")
|
||||
|
||||
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
||||
|
||||
// libass-android for ASS/SSA subtitle rendering
|
||||
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
android:label="Plezy"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:appCategory="video">
|
||||
android:appCategory="video"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.app.Activity
|
||||
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
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler, ActivityAware, ExoPlayerDelegate, MpvPlayerDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/exo_player"
|
||||
private const val EVENT_CHANNEL = "com.plezy/exo_player/events"
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private var playerCore: ExoPlayerCore? = null
|
||||
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
|
||||
private var usingMpvFallback: Boolean = false
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
|
||||
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
|
||||
eventChannel.setStreamHandler(this)
|
||||
|
||||
Log.d(TAG, "Attached to engine")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
Log.d(TAG, "Detached from engine")
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Attached to activity")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
mpvCore?.dispose()
|
||||
mpvCore = null
|
||||
usingMpvFallback = false
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity")
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Reattached to activity for config changes")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
// EventChannel.StreamHandler
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
Log.d(TAG, "Event stream connected")
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
Log.d(TAG, "Event stream disconnected")
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"open" -> handleOpen(call, result)
|
||||
"play" -> handlePlay(result)
|
||||
"pause" -> handlePause(result)
|
||||
"stop" -> handleStop(result)
|
||||
"seek" -> handleSeek(call, result)
|
||||
"setVolume" -> handleSetVolume(call, result)
|
||||
"setRate" -> handleSetRate(call, result)
|
||||
"selectAudioTrack" -> handleSelectAudioTrack(call, result)
|
||||
"selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result)
|
||||
"addSubtitleTrack" -> handleAddSubtitleTrack(call, result)
|
||||
"setVisible" -> handleSetVisible(call, result)
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"isInitialized" -> result.success(
|
||||
if (usingMpvFallback) mpvCore?.isInitialized ?: false
|
||||
else playerCore?.isInitialized ?: false
|
||||
)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInitialize(result: MethodChannel.Result) {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (playerCore?.isInitialized == true) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
result.success(true)
|
||||
return
|
||||
}
|
||||
|
||||
currentActivity.runOnUiThread {
|
||||
try {
|
||||
playerCore = ExoPlayerCore(currentActivity).apply {
|
||||
delegate = this@ExoPlayerPlugin
|
||||
}
|
||||
val success = playerCore?.initialize() ?: false
|
||||
|
||||
// Start hidden
|
||||
playerCore?.setVisible(false)
|
||||
|
||||
Log.d(TAG, "Initialized: $success")
|
||||
result.success(success)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize: ${e.message}", e)
|
||||
result.error("INIT_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.dispose()
|
||||
mpvCore = null
|
||||
} else {
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
}
|
||||
usingMpvFallback = false
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleOpen(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")
|
||||
val headers = call.argument<Map<String, String>>("headers")
|
||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||
val autoPlay = call.argument<Boolean>("autoPlay") ?: true
|
||||
|
||||
if (uri == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
// MPV: Build loadfile command with options
|
||||
val startSeconds = startPositionMs / 1000.0
|
||||
val options = mutableListOf<String>()
|
||||
options.add("start=$startSeconds")
|
||||
if (!autoPlay) options.add("pause=yes")
|
||||
headers?.forEach { (key, value) ->
|
||||
options.add("http-header-fields-append=$key: $value")
|
||||
}
|
||||
val optionsStr = options.joinToString(",")
|
||||
mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr))
|
||||
} else {
|
||||
playerCore?.open(uri, headers, startPositionMs, autoPlay)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
}
|
||||
|
||||
private fun handlePlay(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("pause", "no")
|
||||
} else {
|
||||
playerCore?.play()
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handlePause(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("pause", "yes")
|
||||
} else {
|
||||
playerCore?.pause()
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleStop(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.command(arrayOf("stop"))
|
||||
mpvCore?.setVisible(false)
|
||||
} else {
|
||||
playerCore?.stop()
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSeek(call: MethodCall, result: MethodChannel.Result) {
|
||||
val positionMs = call.argument<Number>("positionMs")?.toLong()
|
||||
|
||||
if (positionMs == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'positionMs'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
val positionSeconds = positionMs / 1000.0
|
||||
mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute"))
|
||||
} else {
|
||||
playerCore?.seekTo(positionMs)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVolume(call: MethodCall, result: MethodChannel.Result) {
|
||||
val volume = call.argument<Number>("volume")?.toFloat()
|
||||
|
||||
if (volume == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'volume'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("volume", volume.toString())
|
||||
} else {
|
||||
playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val rate = call.argument<Number>("rate")?.toFloat()
|
||||
|
||||
if (rate == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'rate'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setProperty("speed", rate.toString())
|
||||
} else {
|
||||
playerCore?.setPlaybackSpeed(rate)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSelectAudioTrack(call: MethodCall, result: MethodChannel.Result) {
|
||||
val trackId = call.argument<String>("trackId")
|
||||
|
||||
if (trackId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'trackId'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
// MPV uses numeric track IDs - extract from string format
|
||||
val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1
|
||||
mpvCore?.setProperty("aid", numericId.toString())
|
||||
} else {
|
||||
playerCore?.selectAudioTrack(trackId)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSelectSubtitleTrack(call: MethodCall, result: MethodChannel.Result) {
|
||||
val trackId = call.argument<String>("trackId")
|
||||
|
||||
// trackId can be null or "no" to disable subtitles
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
if (trackId == null || trackId == "no") {
|
||||
mpvCore?.setProperty("sid", "no")
|
||||
} else {
|
||||
val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1
|
||||
mpvCore?.setProperty("sid", numericId.toString())
|
||||
}
|
||||
} else {
|
||||
playerCore?.selectSubtitleTrack(trackId)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleAddSubtitleTrack(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")
|
||||
val title = call.argument<String>("title")
|
||||
val language = call.argument<String>("language")
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val select = call.argument<Boolean>("select") ?: false
|
||||
|
||||
if (uri == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
return
|
||||
}
|
||||
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
val selectFlag = if (select) "select" else "auto"
|
||||
mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External"))
|
||||
} else {
|
||||
playerCore?.addSubtitleTrack(uri, title, language, mimeType, select)
|
||||
}
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
val visible = call.argument<Boolean>("visible")
|
||||
|
||||
if (visible == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'visible'", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setVisible(visible)
|
||||
} else {
|
||||
playerCore?.setVisible(visible)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setVideoFrameRate(fps, duration)
|
||||
} else {
|
||||
playerCore?.setVideoFrameRate(fps, duration)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.clearVideoFrameRate()
|
||||
} else {
|
||||
playerCore?.clearVideoFrameRate()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "requestAudioFocus")
|
||||
val granted = if (usingMpvFallback) {
|
||||
mpvCore?.requestAudioFocus() ?: false
|
||||
} else {
|
||||
playerCore?.requestAudioFocus() ?: false
|
||||
}
|
||||
result.success(granted)
|
||||
}
|
||||
|
||||
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "abandonAudioFocus")
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.abandonAudioFocus()
|
||||
} else {
|
||||
playerCore?.abandonAudioFocus()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
// ExoPlayerDelegate
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
eventSink?.success(
|
||||
mapOf(
|
||||
"type" to "property",
|
||||
"name" to name,
|
||||
"value" to value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
"name" to name
|
||||
)
|
||||
data?.let { event["data"] = it }
|
||||
eventSink?.success(event)
|
||||
}
|
||||
|
||||
override fun onFormatUnsupported(
|
||||
uri: String,
|
||||
headers: Map<String, String>?,
|
||||
positionMs: Long,
|
||||
errorMessage: String
|
||||
): Boolean {
|
||||
val currentActivity = activity ?: return false
|
||||
|
||||
Log.i(TAG, "Format unsupported, switching to MPV fallback at ${positionMs}ms")
|
||||
|
||||
currentActivity.runOnUiThread {
|
||||
try {
|
||||
// Dispose ExoPlayer
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
|
||||
// Create and initialize MPV
|
||||
mpvCore = MpvPlayerCore(currentActivity).apply {
|
||||
delegate = this@ExoPlayerPlugin
|
||||
}
|
||||
val success = mpvCore?.initialize() ?: false
|
||||
|
||||
if (!success) {
|
||||
Log.e(TAG, "Failed to initialize MPV fallback")
|
||||
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage"))
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
usingMpvFallback = true
|
||||
|
||||
// Configure basic MPV properties for Plex playback
|
||||
mpvCore?.setProperty("hwdec", "auto")
|
||||
mpvCore?.setProperty("vo", "gpu")
|
||||
mpvCore?.setProperty("ao", "audiotrack")
|
||||
|
||||
// Setup property observers
|
||||
mpvCore?.observeProperty("time-pos", "double")
|
||||
mpvCore?.observeProperty("duration", "double")
|
||||
mpvCore?.observeProperty("pause", "flag")
|
||||
mpvCore?.observeProperty("paused-for-cache", "flag")
|
||||
mpvCore?.observeProperty("demuxer-cache-time", "double")
|
||||
mpvCore?.observeProperty("eof-reached", "flag")
|
||||
mpvCore?.observeProperty("track-list", "string")
|
||||
mpvCore?.observeProperty("aid", "string")
|
||||
mpvCore?.observeProperty("sid", "string")
|
||||
mpvCore?.observeProperty("volume", "double")
|
||||
mpvCore?.observeProperty("speed", "double")
|
||||
|
||||
// Show the MPV surface
|
||||
mpvCore?.setVisible(true)
|
||||
|
||||
// Load media at the same position
|
||||
val startSeconds = positionMs / 1000.0
|
||||
val options = mutableListOf<String>()
|
||||
options.add("start=$startSeconds")
|
||||
headers?.forEach { (key, value) ->
|
||||
options.add("http-header-fields-append=$key: $value")
|
||||
}
|
||||
val optionsStr = options.joinToString(",")
|
||||
mpvCore?.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr))
|
||||
|
||||
// Request audio focus
|
||||
mpvCore?.requestAudioFocus()
|
||||
|
||||
// Emit backend-switched event so Flutter can show notification
|
||||
onEvent("backend-switched", null)
|
||||
|
||||
Log.i(TAG, "Successfully switched to MPV fallback")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to switch to MPV fallback", e)
|
||||
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
return true // Fallback is being handled
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.util.Rational
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.RenderMode
|
||||
import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -26,9 +29,21 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRenderMode(): RenderMode {
|
||||
// Use TextureView so Flutter doesn't occupy a SurfaceView layer.
|
||||
// This allows the libass subtitle SurfaceView to sit between video and Flutter UI.
|
||||
return RenderMode.texture
|
||||
}
|
||||
|
||||
override fun getTransparencyMode(): TransparencyMode {
|
||||
// Keep Flutter transparent so video/subtitles are visible below.
|
||||
return TransparencyMode.transparent
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(MpvPlayerPlugin())
|
||||
flutterEngine.plugins.add(ExoPlayerPlugin())
|
||||
|
||||
MethodChannel( flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL ).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.",
|
||||
"showServerNameOnHubs": "Servername bei Hubs anzeigen",
|
||||
"showServerNameOnHubsDescription": "Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.",
|
||||
"playerBackend": "Player-Backend",
|
||||
"exoPlayer": "ExoPlayer (Empfohlen)",
|
||||
"exoPlayerDescription": "Android-nativer Player mit besserer Hardware-Unterstützung",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung",
|
||||
"hardwareDecoding": "Hardware-Decodierung",
|
||||
"hardwareDecodingDescription": "Hardwarebeschleunigung verwenden, sofern verfügbar",
|
||||
"bufferSize": "Puffergröße",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "Keine Elemente verfügbar",
|
||||
"failedToCreatePlayQueue": "Wiedergabewarteschlange konnte nicht erstellt werden",
|
||||
"failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente",
|
||||
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}"
|
||||
"failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}",
|
||||
"switchingToCompatiblePlayer": "Wechsle zu kompatiblem Player..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Stiloptionen",
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Show Server Name on Hubs",
|
||||
"showServerNameOnHubsDescription": "Always display the server name in hub titles. When off, only shows for duplicate hub names.",
|
||||
"playerBackend": "Player Backend",
|
||||
"exoPlayer": "ExoPlayer (Recommended)",
|
||||
"exoPlayerDescription": "Android native player with better hardware support",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Advanced player with more features and ASS subtitle support",
|
||||
"hardwareDecoding": "Hardware Decoding",
|
||||
"hardwareDecodingDescription": "Use hardware acceleration when available",
|
||||
"bufferSize": "Buffer Size",
|
||||
@@ -335,7 +340,8 @@
|
||||
"noItemsAvailable": "No items available",
|
||||
"failedToCreatePlayQueue": "Failed to create play queue",
|
||||
"failedToCreatePlayQueueNoItems": "Failed to create play queue - no items",
|
||||
"failedPlayback": "Failed to ${action}: ${error}"
|
||||
"failedPlayback": "Failed to ${action}: ${error}",
|
||||
"switchingToCompatiblePlayer": "Switching to compatible player..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Styling Options",
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
"useGlobalHubsDescription": "Afficher les hubs de la page d'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.",
|
||||
"showServerNameOnHubs": "Afficher le nom du serveur sur les hubs",
|
||||
"showServerNameOnHubsDescription": "Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s'affichent.",
|
||||
"playerBackend": "Moteur de lecture",
|
||||
"exoPlayer": "ExoPlayer (Recommandé)",
|
||||
"exoPlayerDescription": "Lecteur natif Android avec meilleur support matériel",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS",
|
||||
"hardwareDecoding": "Décodage matériel",
|
||||
"hardwareDecodingDescription": "Utilisez l'accélération matérielle lorsqu'elle est disponible.",
|
||||
"bufferSize": "Taille du Buffer",
|
||||
@@ -335,7 +340,8 @@
|
||||
"noItemsAvailable": "Aucun élément disponible",
|
||||
"failedToCreatePlayQueue": "Échec de la création de la file d'attente de lecture",
|
||||
"failedToCreatePlayQueueNoItems": "Échec de la création de la file d'attente de lecture - aucun élément",
|
||||
"failedPlayback": "Echec de ${action}: ${error}"
|
||||
"failedPlayback": "Echec de ${action}: ${error}",
|
||||
"switchingToCompatiblePlayer": "Passage au lecteur compatible..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Options de style",
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.",
|
||||
"showServerNameOnHubs": "Mostra nome server sugli hub",
|
||||
"showServerNameOnHubsDescription": "Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.",
|
||||
"playerBackend": "Motore di riproduzione",
|
||||
"exoPlayer": "ExoPlayer (Consigliato)",
|
||||
"exoPlayerDescription": "Lettore nativo Android con migliore supporto hardware",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Lettore avanzato con più funzionalità e supporto sottotitoli ASS",
|
||||
"hardwareDecoding": "Decodifica Hardware",
|
||||
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
|
||||
"bufferSize": "Dimensione buffer",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "Nessun elemento disponibile",
|
||||
"failedToCreatePlayQueue": "Impossibile creare la coda di riproduzione",
|
||||
"failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento",
|
||||
"failedPlayback": "Impossibile ${action}: ${error}"
|
||||
"failedPlayback": "Impossibile ${action}: ${error}",
|
||||
"switchingToCompatiblePlayer": "Passaggio al lettore compatibile..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Opzioni stile",
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.",
|
||||
"showServerNameOnHubs": "허브에 서버 이름 표시",
|
||||
"showServerNameOnHubsDescription": "허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.",
|
||||
"playerBackend": "플레이어 백엔드",
|
||||
"exoPlayer": "ExoPlayer (권장)",
|
||||
"exoPlayerDescription": "더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "더 많은 기능과 ASS 자막을 지원하는 고급 플레이어",
|
||||
"hardwareDecoding": "하드웨어 디코딩",
|
||||
"hardwareDecodingDescription": "가능한 경우 하드웨어 가속을 사용합니다",
|
||||
"bufferSize": "버퍼 크기",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "사용 가능한 항목이 없습니다",
|
||||
"failedToCreatePlayQueue": "재생 대기열 생성 실패",
|
||||
"failedToCreatePlayQueueNoItems": "재생 대기열 생성 실패 - 항목 없음",
|
||||
"failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}"
|
||||
"failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}",
|
||||
"switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "스타일 옵션",
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.",
|
||||
"showServerNameOnHubs": "Servernaam tonen bij hubs",
|
||||
"showServerNameOnHubsDescription": "Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.",
|
||||
"playerBackend": "Speler backend",
|
||||
"exoPlayer": "ExoPlayer (Aanbevolen)",
|
||||
"exoPlayerDescription": "Android-native speler met betere hardware-ondersteuning",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Geavanceerde speler met meer functies en ASS-ondertitelondersteuning",
|
||||
"hardwareDecoding": "Hardware decodering",
|
||||
"hardwareDecodingDescription": "Gebruik hardware versnelling indien beschikbaar",
|
||||
"bufferSize": "Buffer grootte",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "Geen items beschikbaar",
|
||||
"failedToCreatePlayQueue": "Kan afspeelwachtrij niet maken",
|
||||
"failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items",
|
||||
"failedPlayback": "Afspelen van ${action} mislukt: ${error}"
|
||||
"failedPlayback": "Afspelen van ${action} mislukt: ${error}",
|
||||
"switchingToCompatiblePlayer": "Overschakelen naar compatibele speler..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Opmaak opties",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 8
|
||||
/// Strings: 4372 (546 per locale)
|
||||
/// Strings: 4420 (552 per locale)
|
||||
///
|
||||
/// Built on 2026-01-21 at 22:57 UTC
|
||||
/// Built on 2026-01-22 at 23:24 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.';
|
||||
@override String get showServerNameOnHubs => 'Servername bei Hubs anzeigen';
|
||||
@override String get showServerNameOnHubsDescription => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.';
|
||||
@override String get playerBackend => 'Player-Backend';
|
||||
@override String get exoPlayer => 'ExoPlayer (Empfohlen)';
|
||||
@override String get exoPlayerDescription => 'Android-nativer Player mit besserer Hardware-Unterstützung';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => 'Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung';
|
||||
@override String get hardwareDecoding => 'Hardware-Decodierung';
|
||||
@override String get hardwareDecodingDescription => 'Hardwarebeschleunigung verwenden, sofern verfügbar';
|
||||
@override String get bufferSize => 'Puffergröße';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => 'Wiedergabewarteschlange konnte nicht erstellt werden';
|
||||
@override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente';
|
||||
@override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => 'Wechsle zu kompatiblem Player...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsDe {
|
||||
'settings.useGlobalHubsDescription' => 'Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.',
|
||||
'settings.showServerNameOnHubs' => 'Servername bei Hubs anzeigen',
|
||||
'settings.showServerNameOnHubsDescription' => 'Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.',
|
||||
'settings.playerBackend' => 'Player-Backend',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Empfohlen)',
|
||||
'settings.exoPlayerDescription' => 'Android-nativer Player mit besserer Hardware-Unterstützung',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Erweiterter Player mit mehr Funktionen und ASS-Untertitel-Unterstützung',
|
||||
'settings.hardwareDecoding' => 'Hardware-Decodierung',
|
||||
'settings.hardwareDecodingDescription' => 'Hardwarebeschleunigung verwenden, sofern verfügbar',
|
||||
'settings.bufferSize' => 'Puffergröße',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsDe {
|
||||
'messages.failedToCreatePlayQueue' => 'Wiedergabewarteschlange konnte nicht erstellt werden',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Wechsle zu kompatiblem Player...',
|
||||
'subtitlingStyling.stylingOptions' => 'Stiloptionen',
|
||||
'subtitlingStyling.fontSize' => 'Schriftgröße',
|
||||
'subtitlingStyling.textColor' => 'Textfarbe',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsDe {
|
||||
'collections.removeFromCollectionError' => ({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}',
|
||||
'watchTogether.title' => 'Gemeinsam Schauen',
|
||||
'watchTogether.description' => 'Inhalte synchron mit Freunden und Familie schauen',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.createSession' => 'Sitzung Erstellen',
|
||||
'watchTogether.creating' => 'Erstellen...',
|
||||
'watchTogether.joinSession' => 'Sitzung Beitreten',
|
||||
'watchTogether.joining' => 'Beitreten...',
|
||||
'watchTogether.controlMode' => 'Steuerungsmodus',
|
||||
'watchTogether.controlModeQuestion' => 'Wer kann die Wiedergabe steuern?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.hostOnly' => 'Nur Host',
|
||||
'watchTogether.anyone' => 'Alle',
|
||||
'watchTogether.hostingSession' => 'Sitzung Hosten',
|
||||
|
||||
@@ -376,6 +376,21 @@ class TranslationsSettingsEn {
|
||||
/// en: 'Always display the server name in hub titles. When off, only shows for duplicate hub names.'
|
||||
String get showServerNameOnHubsDescription => 'Always display the server name in hub titles. When off, only shows for duplicate hub names.';
|
||||
|
||||
/// en: 'Player Backend'
|
||||
String get playerBackend => 'Player Backend';
|
||||
|
||||
/// en: 'ExoPlayer (Recommended)'
|
||||
String get exoPlayer => 'ExoPlayer (Recommended)';
|
||||
|
||||
/// en: 'Android native player with better hardware support'
|
||||
String get exoPlayerDescription => 'Android native player with better hardware support';
|
||||
|
||||
/// en: 'MPV'
|
||||
String get mpv => 'MPV';
|
||||
|
||||
/// en: 'Advanced player with more features and ASS subtitle support'
|
||||
String get mpvDescription => 'Advanced player with more features and ASS subtitle support';
|
||||
|
||||
/// en: 'Hardware Decoding'
|
||||
String get hardwareDecoding => 'Hardware Decoding';
|
||||
|
||||
@@ -1060,6 +1075,9 @@ class TranslationsMessagesEn {
|
||||
|
||||
/// en: 'Failed to ${action}: ${error}'
|
||||
String failedPlayback({required Object action, required Object error}) => 'Failed to ${action}: ${error}';
|
||||
|
||||
/// en: 'Switching to compatible player...'
|
||||
String get switchingToCompatiblePlayer => 'Switching to compatible player...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -2131,6 +2149,11 @@ extension on Translations {
|
||||
'settings.useGlobalHubsDescription' => 'Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.',
|
||||
'settings.showServerNameOnHubs' => 'Show Server Name on Hubs',
|
||||
'settings.showServerNameOnHubsDescription' => 'Always display the server name in hub titles. When off, only shows for duplicate hub names.',
|
||||
'settings.playerBackend' => 'Player Backend',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Recommended)',
|
||||
'settings.exoPlayerDescription' => 'Android native player with better hardware support',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Advanced player with more features and ASS subtitle support',
|
||||
'settings.hardwareDecoding' => 'Hardware Decoding',
|
||||
'settings.hardwareDecodingDescription' => 'Use hardware acceleration when available',
|
||||
'settings.bufferSize' => 'Buffer Size',
|
||||
@@ -2350,6 +2373,7 @@ extension on Translations {
|
||||
'messages.failedToCreatePlayQueue' => 'Failed to create play queue',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Failed to create play queue - no items',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Failed to ${action}: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...',
|
||||
'subtitlingStyling.stylingOptions' => 'Styling Options',
|
||||
'subtitlingStyling.fontSize' => 'Font Size',
|
||||
'subtitlingStyling.textColor' => 'Text Color',
|
||||
@@ -2554,14 +2578,14 @@ extension on Translations {
|
||||
'watchTogether.hostControls' => 'Host controls',
|
||||
'watchTogether.anyoneControls' => 'Anyone controls',
|
||||
'watchTogether.participants' => 'Participants',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.host' => 'Host',
|
||||
'watchTogether.hostBadge' => 'HOST',
|
||||
'watchTogether.youAreHost' => 'You are the host',
|
||||
'watchTogether.watchingWithOthers' => 'Watching with others',
|
||||
'watchTogether.endSession' => 'End Session',
|
||||
'watchTogether.leaveSession' => 'Leave Session',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.endSessionQuestion' => 'End Session?',
|
||||
'watchTogether.leaveSessionQuestion' => 'Leave Session?',
|
||||
'watchTogether.endSessionConfirm' => 'This will end the session for all participants.',
|
||||
|
||||
@@ -206,6 +206,11 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.';
|
||||
@override String get showServerNameOnHubs => 'Afficher le nom du serveur sur les hubs';
|
||||
@override String get showServerNameOnHubsDescription => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.';
|
||||
@override String get playerBackend => 'Moteur de lecture';
|
||||
@override String get exoPlayer => 'ExoPlayer (Recommandé)';
|
||||
@override String get exoPlayerDescription => 'Lecteur natif Android avec meilleur support matériel';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => 'Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS';
|
||||
@override String get hardwareDecoding => 'Décodage matériel';
|
||||
@override String get hardwareDecodingDescription => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.';
|
||||
@override String get bufferSize => 'Taille du Buffer';
|
||||
@@ -495,6 +500,7 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => 'Échec de la création de la file d\'attente de lecture';
|
||||
@override String get failedToCreatePlayQueueNoItems => 'Échec de la création de la file d\'attente de lecture - aucun élément';
|
||||
@override String failedPlayback({required Object action, required Object error}) => 'Echec de ${action}: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => 'Passage au lecteur compatible...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1035,6 +1041,11 @@ extension on TranslationsFr {
|
||||
'settings.useGlobalHubsDescription' => 'Afficher les hubs de la page d\'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.',
|
||||
'settings.showServerNameOnHubs' => 'Afficher le nom du serveur sur les hubs',
|
||||
'settings.showServerNameOnHubsDescription' => 'Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s\'affichent.',
|
||||
'settings.playerBackend' => 'Moteur de lecture',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Recommandé)',
|
||||
'settings.exoPlayerDescription' => 'Lecteur natif Android avec meilleur support matériel',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Lecteur avancé avec plus de fonctionnalités et support des sous-titres ASS',
|
||||
'settings.hardwareDecoding' => 'Décodage matériel',
|
||||
'settings.hardwareDecodingDescription' => 'Utilisez l\'accélération matérielle lorsqu\'elle est disponible.',
|
||||
'settings.bufferSize' => 'Taille du Buffer',
|
||||
@@ -1254,6 +1265,7 @@ extension on TranslationsFr {
|
||||
'messages.failedToCreatePlayQueue' => 'Échec de la création de la file d\'attente de lecture',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Échec de la création de la file d\'attente de lecture - aucun élément',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Echec de ${action}: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Passage au lecteur compatible...',
|
||||
'subtitlingStyling.stylingOptions' => 'Options de style',
|
||||
'subtitlingStyling.fontSize' => 'Taille de la police',
|
||||
'subtitlingStyling.textColor' => 'Couleur du texte',
|
||||
@@ -1458,14 +1470,14 @@ extension on TranslationsFr {
|
||||
'watchTogether.hostControls' => 'Commandes de l\'hôte',
|
||||
'watchTogether.anyoneControls' => 'Tout le monde contrôle',
|
||||
'watchTogether.participants' => 'Participants',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.host' => 'Hôte',
|
||||
'watchTogether.hostBadge' => 'HOST',
|
||||
'watchTogether.youAreHost' => 'Vous êtes l\'hôte',
|
||||
'watchTogether.watchingWithOthers' => 'Regarder avec d\'autres personnes',
|
||||
'watchTogether.endSession' => 'Fin de session',
|
||||
'watchTogether.leaveSession' => 'Quitter la session',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.endSessionQuestion' => 'Terminer la session ?',
|
||||
'watchTogether.leaveSessionQuestion' => 'Quitter la session ?',
|
||||
'watchTogether.endSessionConfirm' => 'Cela mettra fin à la session pour tous les participants.',
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.';
|
||||
@override String get showServerNameOnHubs => 'Mostra nome server sugli hub';
|
||||
@override String get showServerNameOnHubsDescription => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.';
|
||||
@override String get playerBackend => 'Motore di riproduzione';
|
||||
@override String get exoPlayer => 'ExoPlayer (Consigliato)';
|
||||
@override String get exoPlayerDescription => 'Lettore nativo Android con migliore supporto hardware';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => 'Lettore avanzato con più funzionalità e supporto sottotitoli ASS';
|
||||
@override String get hardwareDecoding => 'Decodifica Hardware';
|
||||
@override String get hardwareDecodingDescription => 'Utilizza l\'accelerazione hardware quando disponibile';
|
||||
@override String get bufferSize => 'Dimensione buffer';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => 'Impossibile creare la coda di riproduzione';
|
||||
@override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento';
|
||||
@override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => 'Passaggio al lettore compatibile...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsIt {
|
||||
'settings.useGlobalHubsDescription' => 'Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.',
|
||||
'settings.showServerNameOnHubs' => 'Mostra nome server sugli hub',
|
||||
'settings.showServerNameOnHubsDescription' => 'Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.',
|
||||
'settings.playerBackend' => 'Motore di riproduzione',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Consigliato)',
|
||||
'settings.exoPlayerDescription' => 'Lettore nativo Android con migliore supporto hardware',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Lettore avanzato con più funzionalità e supporto sottotitoli ASS',
|
||||
'settings.hardwareDecoding' => 'Decodifica Hardware',
|
||||
'settings.hardwareDecodingDescription' => 'Utilizza l\'accelerazione hardware quando disponibile',
|
||||
'settings.bufferSize' => 'Dimensione buffer',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsIt {
|
||||
'messages.failedToCreatePlayQueue' => 'Impossibile creare la coda di riproduzione',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Impossibile creare la coda di riproduzione - nessun elemento',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Passaggio al lettore compatibile...',
|
||||
'subtitlingStyling.stylingOptions' => 'Opzioni stile',
|
||||
'subtitlingStyling.fontSize' => 'Dimensione',
|
||||
'subtitlingStyling.textColor' => 'Colore testo',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsIt {
|
||||
'collections.removeFromCollectionError' => ({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}',
|
||||
'watchTogether.title' => 'Guarda Insieme',
|
||||
'watchTogether.description' => 'Guarda contenuti in sincronia con amici e familiari',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.createSession' => 'Crea Sessione',
|
||||
'watchTogether.creating' => 'Creazione...',
|
||||
'watchTogether.joinSession' => 'Unisciti alla Sessione',
|
||||
'watchTogether.joining' => 'Connessione...',
|
||||
'watchTogether.controlMode' => 'Modalità di Controllo',
|
||||
'watchTogether.controlModeQuestion' => 'Chi può controllare la riproduzione?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.hostOnly' => 'Solo Host',
|
||||
'watchTogether.anyone' => 'Tutti',
|
||||
'watchTogether.hostingSession' => 'Hosting Sessione',
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.';
|
||||
@override String get showServerNameOnHubs => '허브에 서버 이름 표시';
|
||||
@override String get showServerNameOnHubsDescription => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.';
|
||||
@override String get playerBackend => '플레이어 백엔드';
|
||||
@override String get exoPlayer => 'ExoPlayer (권장)';
|
||||
@override String get exoPlayerDescription => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => '더 많은 기능과 ASS 자막을 지원하는 고급 플레이어';
|
||||
@override String get hardwareDecoding => '하드웨어 디코딩';
|
||||
@override String get hardwareDecodingDescription => '가능한 경우 하드웨어 가속을 사용합니다';
|
||||
@override String get bufferSize => '버퍼 크기';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => '재생 대기열 생성 실패';
|
||||
@override String get failedToCreatePlayQueueNoItems => '재생 대기열 생성 실패 - 항목 없음';
|
||||
@override String failedPlayback({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => '호환되는 플레이어로 전환 중...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsKo {
|
||||
'settings.useGlobalHubsDescription' => '공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.',
|
||||
'settings.showServerNameOnHubs' => '허브에 서버 이름 표시',
|
||||
'settings.showServerNameOnHubsDescription' => '허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.',
|
||||
'settings.playerBackend' => '플레이어 백엔드',
|
||||
'settings.exoPlayer' => 'ExoPlayer (권장)',
|
||||
'settings.exoPlayerDescription' => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => '더 많은 기능과 ASS 자막을 지원하는 고급 플레이어',
|
||||
'settings.hardwareDecoding' => '하드웨어 디코딩',
|
||||
'settings.hardwareDecodingDescription' => '가능한 경우 하드웨어 가속을 사용합니다',
|
||||
'settings.bufferSize' => '버퍼 크기',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsKo {
|
||||
'messages.failedToCreatePlayQueue' => '재생 대기열 생성 실패',
|
||||
'messages.failedToCreatePlayQueueNoItems' => '재생 대기열 생성 실패 - 항목 없음',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => '호환되는 플레이어로 전환 중...',
|
||||
'subtitlingStyling.stylingOptions' => '스타일 옵션',
|
||||
'subtitlingStyling.fontSize' => '글자 크기',
|
||||
'subtitlingStyling.textColor' => '텍스트 색상',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsKo {
|
||||
'watchTogether.hostControlsPlayback' => '호스트 재생 제어',
|
||||
'watchTogether.anyoneCanControl' => '누구나 재생 제어 가능',
|
||||
'watchTogether.hostControls' => '호스트 제어',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.anyoneControls' => '누구나 제어',
|
||||
'watchTogether.participants' => '참가자',
|
||||
'watchTogether.host' => '호스트',
|
||||
'watchTogether.hostBadge' => '호스트',
|
||||
'watchTogether.youAreHost' => '당신은 호스트 입니다',
|
||||
'watchTogether.watchingWithOthers' => '다른 사람과 함께 시청 중',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.endSession' => '세션 종료',
|
||||
'watchTogether.leaveSession' => '세션 탈퇴',
|
||||
'watchTogether.endSessionQuestion' => '세션을 종료 하시겠습니까?',
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.';
|
||||
@override String get showServerNameOnHubs => 'Servernaam tonen bij hubs';
|
||||
@override String get showServerNameOnHubsDescription => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.';
|
||||
@override String get playerBackend => 'Speler backend';
|
||||
@override String get exoPlayer => 'ExoPlayer (Aanbevolen)';
|
||||
@override String get exoPlayerDescription => 'Android-native speler met betere hardware-ondersteuning';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => 'Geavanceerde speler met meer functies en ASS-ondertitelondersteuning';
|
||||
@override String get hardwareDecoding => 'Hardware decodering';
|
||||
@override String get hardwareDecodingDescription => 'Gebruik hardware versnelling indien beschikbaar';
|
||||
@override String get bufferSize => 'Buffer grootte';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => 'Kan afspeelwachtrij niet maken';
|
||||
@override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items';
|
||||
@override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => 'Overschakelen naar compatibele speler...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsNl {
|
||||
'settings.useGlobalHubsDescription' => 'Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.',
|
||||
'settings.showServerNameOnHubs' => 'Servernaam tonen bij hubs',
|
||||
'settings.showServerNameOnHubsDescription' => 'Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.',
|
||||
'settings.playerBackend' => 'Speler backend',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Aanbevolen)',
|
||||
'settings.exoPlayerDescription' => 'Android-native speler met betere hardware-ondersteuning',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Geavanceerde speler met meer functies en ASS-ondertitelondersteuning',
|
||||
'settings.hardwareDecoding' => 'Hardware decodering',
|
||||
'settings.hardwareDecodingDescription' => 'Gebruik hardware versnelling indien beschikbaar',
|
||||
'settings.bufferSize' => 'Buffer grootte',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsNl {
|
||||
'messages.failedToCreatePlayQueue' => 'Kan afspeelwachtrij niet maken',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Kan afspeelwachtrij niet maken - geen items',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Overschakelen naar compatibele speler...',
|
||||
'subtitlingStyling.stylingOptions' => 'Opmaak opties',
|
||||
'subtitlingStyling.fontSize' => 'Lettergrootte',
|
||||
'subtitlingStyling.textColor' => 'Tekstkleur',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsNl {
|
||||
'collections.removeFromCollectionError' => ({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}',
|
||||
'watchTogether.title' => 'Samen Kijken',
|
||||
'watchTogether.description' => 'Kijk synchroon met vrienden en familie',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.createSession' => 'Sessie Maken',
|
||||
'watchTogether.creating' => 'Maken...',
|
||||
'watchTogether.joinSession' => 'Sessie Deelnemen',
|
||||
'watchTogether.joining' => 'Deelnemen...',
|
||||
'watchTogether.controlMode' => 'Controlemodus',
|
||||
'watchTogether.controlModeQuestion' => 'Wie kan het afspelen bedienen?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.hostOnly' => 'Alleen Host',
|
||||
'watchTogether.anyone' => 'Iedereen',
|
||||
'watchTogether.hostingSession' => 'Sessie Hosten',
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.';
|
||||
@override String get showServerNameOnHubs => 'Visa servernamn på hubbar';
|
||||
@override String get showServerNameOnHubsDescription => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.';
|
||||
@override String get playerBackend => 'Spelarmotor';
|
||||
@override String get exoPlayer => 'ExoPlayer (Rekommenderad)';
|
||||
@override String get exoPlayerDescription => 'Android-nativ spelare med bättre hårdvarustöd';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => 'Avancerad spelare med fler funktioner och ASS-undertextstöd';
|
||||
@override String get hardwareDecoding => 'Hårdvaruavkodning';
|
||||
@override String get hardwareDecodingDescription => 'Använd hårdvaruacceleration när tillgängligt';
|
||||
@override String get bufferSize => 'Bufferstorlek';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => 'Det gick inte att skapa uppspelningskö';
|
||||
@override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö – inga objekt';
|
||||
@override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => 'Byter till kompatibel spelare...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsSv {
|
||||
'settings.useGlobalHubsDescription' => 'Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.',
|
||||
'settings.showServerNameOnHubs' => 'Visa servernamn på hubbar',
|
||||
'settings.showServerNameOnHubsDescription' => 'Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.',
|
||||
'settings.playerBackend' => 'Spelarmotor',
|
||||
'settings.exoPlayer' => 'ExoPlayer (Rekommenderad)',
|
||||
'settings.exoPlayerDescription' => 'Android-nativ spelare med bättre hårdvarustöd',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => 'Avancerad spelare med fler funktioner och ASS-undertextstöd',
|
||||
'settings.hardwareDecoding' => 'Hårdvaruavkodning',
|
||||
'settings.hardwareDecodingDescription' => 'Använd hårdvaruacceleration när tillgängligt',
|
||||
'settings.bufferSize' => 'Bufferstorlek',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsSv {
|
||||
'messages.failedToCreatePlayQueue' => 'Det gick inte att skapa uppspelningskö',
|
||||
'messages.failedToCreatePlayQueueNoItems' => 'Det gick inte att skapa uppspelningskö – inga objekt',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => 'Byter till kompatibel spelare...',
|
||||
'subtitlingStyling.stylingOptions' => 'Stilalternativ',
|
||||
'subtitlingStyling.fontSize' => 'Teckenstorlek',
|
||||
'subtitlingStyling.textColor' => 'Textfärg',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsSv {
|
||||
'collections.removeFromCollectionError' => ({required Object error}) => 'Fel vid borttagning från samling: ${error}',
|
||||
'watchTogether.title' => 'Titta Tillsammans',
|
||||
'watchTogether.description' => 'Titta på innehåll synkroniserat med vänner och familj',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.createSession' => 'Skapa Session',
|
||||
'watchTogether.creating' => 'Skapar...',
|
||||
'watchTogether.joinSession' => 'Gå med i Session',
|
||||
'watchTogether.joining' => 'Ansluter...',
|
||||
'watchTogether.controlMode' => 'Kontrollläge',
|
||||
'watchTogether.controlModeQuestion' => 'Vem kan styra uppspelningen?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.hostOnly' => 'Endast Värd',
|
||||
'watchTogether.anyone' => 'Alla',
|
||||
'watchTogether.hostingSession' => 'Värd för Session',
|
||||
|
||||
@@ -207,6 +207,11 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
|
||||
@override String get useGlobalHubsDescription => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。';
|
||||
@override String get showServerNameOnHubs => '在推荐栏显示服务器名称';
|
||||
@override String get showServerNameOnHubsDescription => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。';
|
||||
@override String get playerBackend => '播放器引擎';
|
||||
@override String get exoPlayer => 'ExoPlayer(推荐)';
|
||||
@override String get exoPlayerDescription => 'Android 原生播放器,硬件支持更好';
|
||||
@override String get mpv => 'MPV';
|
||||
@override String get mpvDescription => '功能更多的高级播放器,支持 ASS 字幕';
|
||||
@override String get hardwareDecoding => '硬件解码';
|
||||
@override String get hardwareDecodingDescription => '如果可用,使用硬件加速';
|
||||
@override String get bufferSize => '缓冲区大小';
|
||||
@@ -496,6 +501,7 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn {
|
||||
@override String get failedToCreatePlayQueue => '创建播放队列失败';
|
||||
@override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目';
|
||||
@override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}';
|
||||
@override String get switchingToCompatiblePlayer => '正在切换到兼容的播放器...';
|
||||
}
|
||||
|
||||
// Path: subtitlingStyling
|
||||
@@ -1038,6 +1044,11 @@ extension on TranslationsZh {
|
||||
'settings.useGlobalHubsDescription' => '显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。',
|
||||
'settings.showServerNameOnHubs' => '在推荐栏显示服务器名称',
|
||||
'settings.showServerNameOnHubsDescription' => '始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。',
|
||||
'settings.playerBackend' => '播放器引擎',
|
||||
'settings.exoPlayer' => 'ExoPlayer(推荐)',
|
||||
'settings.exoPlayerDescription' => 'Android 原生播放器,硬件支持更好',
|
||||
'settings.mpv' => 'MPV',
|
||||
'settings.mpvDescription' => '功能更多的高级播放器,支持 ASS 字幕',
|
||||
'settings.hardwareDecoding' => '硬件解码',
|
||||
'settings.hardwareDecodingDescription' => '如果可用,使用硬件加速',
|
||||
'settings.bufferSize' => '缓冲区大小',
|
||||
@@ -1257,6 +1268,7 @@ extension on TranslationsZh {
|
||||
'messages.failedToCreatePlayQueue' => '创建播放队列失败',
|
||||
'messages.failedToCreatePlayQueueNoItems' => '创建播放队列失败 - 没有项目',
|
||||
'messages.failedPlayback' => ({required Object action, required Object error}) => '无法${action}: ${error}',
|
||||
'messages.switchingToCompatiblePlayer' => '正在切换到兼容的播放器...',
|
||||
'subtitlingStyling.stylingOptions' => '样式选项',
|
||||
'subtitlingStyling.fontSize' => '字号',
|
||||
'subtitlingStyling.textColor' => '文本颜色',
|
||||
@@ -1460,14 +1472,14 @@ extension on TranslationsZh {
|
||||
'collections.removeFromCollectionError' => ({required Object error}) => '从合集移除时出错:${error}',
|
||||
'watchTogether.title' => '一起看',
|
||||
'watchTogether.description' => '与朋友和家人同步观看内容',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.createSession' => '创建会话',
|
||||
'watchTogether.creating' => '创建中...',
|
||||
'watchTogether.joinSession' => '加入会话',
|
||||
'watchTogether.joining' => '加入中...',
|
||||
'watchTogether.controlMode' => '控制模式',
|
||||
'watchTogether.controlModeQuestion' => '谁可以控制播放?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.hostOnly' => '仅主持人',
|
||||
'watchTogether.anyone' => '任何人',
|
||||
'watchTogether.hostingSession' => '主持会话',
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.",
|
||||
"showServerNameOnHubs": "Visa servernamn på hubbar",
|
||||
"showServerNameOnHubsDescription": "Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.",
|
||||
"playerBackend": "Spelarmotor",
|
||||
"exoPlayer": "ExoPlayer (Rekommenderad)",
|
||||
"exoPlayerDescription": "Android-nativ spelare med bättre hårdvarustöd",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "Avancerad spelare med fler funktioner och ASS-undertextstöd",
|
||||
"hardwareDecoding": "Hårdvaruavkodning",
|
||||
"hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt",
|
||||
"bufferSize": "Bufferstorlek",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "Inga objekt tillgängliga",
|
||||
"failedToCreatePlayQueue": "Det gick inte att skapa uppspelningskö",
|
||||
"failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt",
|
||||
"failedPlayback": "Kunde inte ${action}: ${error}"
|
||||
"failedPlayback": "Kunde inte ${action}: ${error}",
|
||||
"switchingToCompatiblePlayer": "Byter till kompatibel spelare..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "Stilalternativ",
|
||||
|
||||
@@ -94,6 +94,11 @@
|
||||
"useGlobalHubsDescription": "显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。",
|
||||
"showServerNameOnHubs": "在推荐栏显示服务器名称",
|
||||
"showServerNameOnHubsDescription": "始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。",
|
||||
"playerBackend": "播放器引擎",
|
||||
"exoPlayer": "ExoPlayer(推荐)",
|
||||
"exoPlayerDescription": "Android 原生播放器,硬件支持更好",
|
||||
"mpv": "MPV",
|
||||
"mpvDescription": "功能更多的高级播放器,支持 ASS 字幕",
|
||||
"hardwareDecoding": "硬件解码",
|
||||
"hardwareDecodingDescription": "如果可用,使用硬件加速",
|
||||
"bufferSize": "缓冲区大小",
|
||||
@@ -336,7 +341,8 @@
|
||||
"noItemsAvailable": "没有可用的项目",
|
||||
"failedToCreatePlayQueue": "创建播放队列失败",
|
||||
"failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目",
|
||||
"failedPlayback": "无法${action}: ${error}"
|
||||
"failedPlayback": "无法${action}: ${error}",
|
||||
"switchingToCompatiblePlayer": "正在切换到兼容的播放器..."
|
||||
},
|
||||
"subtitlingStyling": {
|
||||
"stylingOptions": "样式选项",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import '../models.dart';
|
||||
import 'player_android.dart';
|
||||
import 'player_native.dart';
|
||||
import 'player_state.dart';
|
||||
import 'player_streams.dart';
|
||||
@@ -229,11 +230,25 @@ abstract class Player {
|
||||
/// Creates a new player instance.
|
||||
///
|
||||
/// Returns a platform-specific implementation:
|
||||
/// - macOS/iOS/Android: [PlayerNative] using MPVKit/libmpv with texture rendering
|
||||
/// - macOS/iOS: [PlayerNative] using MPVKit/libmpv with Metal rendering
|
||||
/// - Android: [PlayerAndroid] using ExoPlayer (default) or [PlayerNative] using MPV (fallback)
|
||||
/// - Windows: [PlayerWindows] using libmpv with native window embedding
|
||||
/// - Linux: [PlayerLinux] using libmpv with OpenGL rendering via GtkGLArea
|
||||
factory Player() {
|
||||
if (Platform.isMacOS || Platform.isIOS || Platform.isAndroid) {
|
||||
///
|
||||
/// On Android, pass [useExoPlayer] to override the default:
|
||||
/// - true: Use ExoPlayer (default, better hardware support)
|
||||
/// - false: Use MPV (more features, ASS subtitle rendering)
|
||||
factory Player({bool? useExoPlayer}) {
|
||||
if (Platform.isAndroid) {
|
||||
// Default to ExoPlayer on Android, with MPV as fallback
|
||||
// The caller should pass useExoPlayer based on SettingsService.getUseExoPlayer()
|
||||
final useExo = useExoPlayer ?? true;
|
||||
if (useExo) {
|
||||
return PlayerAndroid(); // ExoPlayer (default)
|
||||
}
|
||||
return PlayerNative(); // MPV fallback
|
||||
}
|
||||
if (Platform.isMacOS || Platform.isIOS) {
|
||||
return PlayerNative();
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../models.dart';
|
||||
import 'player.dart';
|
||||
import 'player_state.dart';
|
||||
import 'player_streams.dart';
|
||||
|
||||
/// Android implementation of [Player] using ExoPlayer.
|
||||
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
|
||||
class PlayerAndroid implements Player {
|
||||
static const _methodChannel = MethodChannel('com.plezy/exo_player');
|
||||
static const _eventChannel = EventChannel('com.plezy/exo_player/events');
|
||||
|
||||
PlayerState _state = const PlayerState();
|
||||
|
||||
@override
|
||||
PlayerState get state => _state;
|
||||
|
||||
late final PlayerStreams _streams;
|
||||
|
||||
@override
|
||||
PlayerStreams get streams => _streams;
|
||||
|
||||
@override
|
||||
int? get textureId => null; // Uses SurfaceView, not Flutter texture
|
||||
|
||||
// Stream controllers
|
||||
final _playingController = StreamController<bool>.broadcast();
|
||||
final _completedController = StreamController<bool>.broadcast();
|
||||
final _bufferingController = StreamController<bool>.broadcast();
|
||||
final _positionController = StreamController<Duration>.broadcast();
|
||||
final _durationController = StreamController<Duration>.broadcast();
|
||||
final _bufferController = StreamController<Duration>.broadcast();
|
||||
final _volumeController = StreamController<double>.broadcast();
|
||||
final _rateController = StreamController<double>.broadcast();
|
||||
final _tracksController = StreamController<Tracks>.broadcast();
|
||||
final _trackController = StreamController<TrackSelection>.broadcast();
|
||||
final _logController = StreamController<PlayerLog>.broadcast();
|
||||
final _errorController = StreamController<String>.broadcast();
|
||||
final _audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final _playbackRestartController = StreamController<void>.broadcast();
|
||||
final _backendSwitchedController = StreamController<void>.broadcast();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
bool _initialized = false;
|
||||
|
||||
PlayerAndroid() {
|
||||
_streams = PlayerStreams(
|
||||
playing: _playingController.stream,
|
||||
completed: _completedController.stream,
|
||||
buffering: _bufferingController.stream,
|
||||
position: _positionController.stream,
|
||||
duration: _durationController.stream,
|
||||
buffer: _bufferController.stream,
|
||||
volume: _volumeController.stream,
|
||||
rate: _rateController.stream,
|
||||
tracks: _tracksController.stream,
|
||||
track: _trackController.stream,
|
||||
log: _logController.stream,
|
||||
error: _errorController.stream,
|
||||
audioDevice: _audioDeviceController.stream,
|
||||
audioDevices: _audioDevicesController.stream,
|
||||
playbackRestart: _playbackRestartController.stream,
|
||||
backendSwitched: _backendSwitchedController.stream,
|
||||
);
|
||||
|
||||
_setupEventListener();
|
||||
|
||||
// Forward logs to app logger
|
||||
_logController.stream.listen(_forwardToAppLogger);
|
||||
}
|
||||
|
||||
void _forwardToAppLogger(PlayerLog log) {
|
||||
final message = '[ExoPlayer:${log.prefix}] ${log.text}'.trimRight();
|
||||
switch (log.level) {
|
||||
case PlayerLogLevel.fatal:
|
||||
case PlayerLogLevel.error:
|
||||
appLogger.e(message);
|
||||
case PlayerLogLevel.warn:
|
||||
appLogger.w(message);
|
||||
case PlayerLogLevel.info:
|
||||
case PlayerLogLevel.verbose:
|
||||
appLogger.i(message);
|
||||
case PlayerLogLevel.debug:
|
||||
case PlayerLogLevel.trace:
|
||||
appLogger.d(message);
|
||||
case PlayerLogLevel.none:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _setupEventListener() {
|
||||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
_handleEvent,
|
||||
onError: (error) {
|
||||
_errorController.add(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
|
||||
final type = event['type'] as String?;
|
||||
final name = event['name'] as String?;
|
||||
|
||||
if (type == 'property' && name != null) {
|
||||
_handlePropertyChange(name, event['value']);
|
||||
} else if (type == 'event' && name != null) {
|
||||
_handlePlayerEvent(name, event['data'] as Map?);
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePropertyChange(String name, dynamic value) {
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
final playing = value == false;
|
||||
_state = _state.copyWith(playing: playing);
|
||||
_playingController.add(playing);
|
||||
break;
|
||||
|
||||
case 'eof-reached':
|
||||
final completed = value == true;
|
||||
_state = _state.copyWith(completed: completed);
|
||||
_completedController.add(completed);
|
||||
break;
|
||||
|
||||
case 'paused-for-cache':
|
||||
final buffering = value == true;
|
||||
_state = _state.copyWith(buffering: buffering);
|
||||
_bufferingController.add(buffering);
|
||||
break;
|
||||
|
||||
case 'time-pos':
|
||||
if (value is num) {
|
||||
final position = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(position: position);
|
||||
_positionController.add(position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'duration':
|
||||
if (value is num) {
|
||||
final duration = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(duration: duration);
|
||||
_durationController.add(duration);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demuxer-cache-time':
|
||||
if (value is num) {
|
||||
final buffer = Duration(milliseconds: (value * 1000).toInt());
|
||||
_state = _state.copyWith(buffer: buffer);
|
||||
_bufferController.add(buffer);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
if (value is num) {
|
||||
final volume = value.toDouble();
|
||||
_state = _state.copyWith(volume: volume);
|
||||
_volumeController.add(volume);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'speed':
|
||||
if (value is num) {
|
||||
final rate = value.toDouble();
|
||||
_state = _state.copyWith(rate: rate);
|
||||
_rateController.add(rate);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'track-list':
|
||||
List? trackList;
|
||||
if (value is List) {
|
||||
trackList = value;
|
||||
} else if (value is String && value.isNotEmpty) {
|
||||
// MPV sends track-list as JSON string after fallback
|
||||
try {
|
||||
final parsed = jsonDecode(value);
|
||||
if (parsed is List) trackList = parsed;
|
||||
} catch (_) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
if (trackList != null) {
|
||||
final tracks = _parseTrackList(trackList);
|
||||
_state = _state.copyWith(tracks: tracks);
|
||||
_tracksController.add(tracks);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'aid':
|
||||
_updateSelectedAudioTrack(value);
|
||||
break;
|
||||
|
||||
case 'sid':
|
||||
_updateSelectedSubtitleTrack(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePlayerEvent(String name, Map? data) {
|
||||
switch (name) {
|
||||
case 'end-file':
|
||||
final reason = data?['reason'] as String?;
|
||||
if (reason == 'eof') {
|
||||
_state = _state.copyWith(completed: true);
|
||||
_completedController.add(true);
|
||||
} else if (reason == 'error') {
|
||||
_errorController.add(data?['message'] as String? ?? 'Playback error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file-loaded':
|
||||
_state = _state.copyWith(completed: false);
|
||||
_completedController.add(false);
|
||||
break;
|
||||
|
||||
case 'playback-restart':
|
||||
_playbackRestartController.add(null);
|
||||
break;
|
||||
|
||||
case 'backend-switched':
|
||||
// Native player switched from ExoPlayer to MPV due to unsupported format
|
||||
_backendSwitchedController.add(null);
|
||||
break;
|
||||
|
||||
case 'log-message':
|
||||
final prefix = data?['prefix'] as String? ?? '';
|
||||
final levelStr = data?['level'] as String? ?? 'info';
|
||||
final text = data?['text'] as String? ?? '';
|
||||
final level = _parseLogLevel(levelStr);
|
||||
_logController.add(PlayerLog(level: level, prefix: prefix, text: text));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerLogLevel _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'fatal' => PlayerLogLevel.fatal,
|
||||
'error' => PlayerLogLevel.error,
|
||||
'warn' => PlayerLogLevel.warn,
|
||||
'info' => PlayerLogLevel.info,
|
||||
'v' || 'verbose' => PlayerLogLevel.verbose,
|
||||
'debug' => PlayerLogLevel.debug,
|
||||
'trace' => PlayerLogLevel.trace,
|
||||
_ => PlayerLogLevel.info,
|
||||
};
|
||||
}
|
||||
|
||||
Tracks _parseTrackList(List trackList) {
|
||||
final audioTracks = <AudioTrack>[];
|
||||
final subtitleTracks = <SubtitleTrack>[];
|
||||
|
||||
for (final track in trackList) {
|
||||
if (track is! Map) continue;
|
||||
|
||||
final type = track['type'] as String?;
|
||||
final id = track['id']?.toString() ?? '';
|
||||
|
||||
if (type == 'audio') {
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
),
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
uri: track['external-filename'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Tracks(audio: audioTracks, subtitle: subtitleTracks);
|
||||
}
|
||||
|
||||
void _updateSelectedAudioTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
AudioTrack? selectedTrack;
|
||||
|
||||
if (id != null && id != 'no') {
|
||||
selectedTrack = _state.tracks.audio.cast<AudioTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
void _updateSelectedSubtitleTrack(dynamic trackId) {
|
||||
final id = trackId?.toString();
|
||||
SubtitleTrack? selectedTrack;
|
||||
|
||||
if (id == null || id == 'no') {
|
||||
selectedTrack = SubtitleTrack.off;
|
||||
} else {
|
||||
selectedTrack = _state.tracks.subtitle.cast<SubtitleTrack?>().firstWhere((t) => t?.id == id, orElse: () => null);
|
||||
}
|
||||
|
||||
_state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
|
||||
_trackController.add(_state.track);
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
final result = await _methodChannel.invokeMethod<bool>('initialize');
|
||||
_initialized = result == true;
|
||||
if (!_initialized) {
|
||||
throw Exception('Failed to initialize ExoPlayer');
|
||||
}
|
||||
} catch (e) {
|
||||
_errorController.add('Initialization failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _checkDisposed() {
|
||||
if (_disposed) {
|
||||
throw StateError('Player has been disposed');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Control
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> open(Media media, {bool play = true}) async {
|
||||
_checkDisposed();
|
||||
await _ensureInitialized();
|
||||
|
||||
// Show the video layer
|
||||
await setVisible(true);
|
||||
|
||||
await _methodChannel.invokeMethod('open', {
|
||||
'uri': media.uri,
|
||||
'headers': media.headers,
|
||||
'startPositionMs': media.start?.inMilliseconds ?? 0,
|
||||
'autoPlay': play,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('play');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('pause');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playOrPause() async {
|
||||
_checkDisposed();
|
||||
if (_state.playing) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('stop');
|
||||
await setVisible(false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('seek', {'positionMs': position.inMilliseconds});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Track Selection
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('selectAudioTrack', {'trackId': track.id});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> selectSubtitleTrack(SubtitleTrack track) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('selectSubtitleTrack', {'trackId': track.id});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('addSubtitleTrack', {
|
||||
'uri': uri,
|
||||
'title': title,
|
||||
'language': language,
|
||||
'select': select,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Volume and Rate
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('setVolume', {'volume': volume});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRate(double rate) async {
|
||||
_checkDisposed();
|
||||
await _methodChannel.invokeMethod('setRate', {'rate': rate});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
// ExoPlayer doesn't support audio device selection on Android
|
||||
// This is a no-op
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MPV Properties (Compatibility Layer)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setProperty(String name, String value) async {
|
||||
_checkDisposed();
|
||||
// ExoPlayer doesn't use MPV properties, but we handle common ones
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
if (value == 'yes') {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
break;
|
||||
case 'volume':
|
||||
await setVolume(double.tryParse(value) ?? 100);
|
||||
break;
|
||||
case 'speed':
|
||||
await setRate(double.tryParse(value) ?? 1.0);
|
||||
break;
|
||||
// Other properties are no-ops for ExoPlayer
|
||||
default:
|
||||
// No-op for MPV-specific properties
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getProperty(String name) async {
|
||||
_checkDisposed();
|
||||
// Return state-based values for common properties
|
||||
switch (name) {
|
||||
case 'pause':
|
||||
return _state.playing ? 'no' : 'yes';
|
||||
case 'volume':
|
||||
return _state.volume.toString();
|
||||
case 'speed':
|
||||
return _state.rate.toString();
|
||||
case 'time-pos':
|
||||
return (_state.position.inMilliseconds / 1000.0).toString();
|
||||
case 'duration':
|
||||
return (_state.duration.inMilliseconds / 1000.0).toString();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> command(List<String> args) async {
|
||||
_checkDisposed();
|
||||
// Handle MPV commands by translating to ExoPlayer equivalents
|
||||
if (args.isEmpty) return;
|
||||
|
||||
switch (args[0]) {
|
||||
case 'loadfile':
|
||||
if (args.length > 1) {
|
||||
await open(Media(args[1]));
|
||||
}
|
||||
break;
|
||||
case 'seek':
|
||||
if (args.length > 1) {
|
||||
final seconds = double.tryParse(args[1]) ?? 0;
|
||||
final mode = args.length > 2 ? args[2] : 'relative';
|
||||
if (mode == 'absolute') {
|
||||
await seek(Duration(milliseconds: (seconds * 1000).toInt()));
|
||||
} else {
|
||||
final newPos = _state.position + Duration(milliseconds: (seconds * 1000).toInt());
|
||||
await seek(newPos);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'stop':
|
||||
await stop();
|
||||
break;
|
||||
case 'sub-add':
|
||||
if (args.length > 1) {
|
||||
final select = args.length > 2 && args[2] == 'select';
|
||||
await addSubtitleTrack(uri: args[1], select: select);
|
||||
}
|
||||
break;
|
||||
// Other commands are no-ops
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Passthrough (Not supported by ExoPlayer)
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
// ExoPlayer doesn't support direct audio passthrough configuration
|
||||
// This is handled by the device's audio settings
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Visibility
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> setVisible(bool visible) async {
|
||||
_checkDisposed();
|
||||
|
||||
try {
|
||||
await _methodChannel.invokeMethod('setVisible', {'visible': visible});
|
||||
return true;
|
||||
} catch (e) {
|
||||
_errorController.add('Failed to set visibility: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
// Not needed for ExoPlayer on Android
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Frame Rate Matching
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> setVideoFrameRate(double fps, int durationMs) async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('setVideoFrameRate', {'fps': fps, 'duration': durationMs});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearVideoFrameRate() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('clearVideoFrameRate');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audio Focus
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<bool> requestAudioFocus() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return false;
|
||||
|
||||
final result = await _methodChannel.invokeMethod<bool>('requestAudioFocus');
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> abandonAudioFocus() async {
|
||||
_checkDisposed();
|
||||
if (!_initialized) return;
|
||||
|
||||
await _methodChannel.invokeMethod('abandonAudioFocus');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _eventSubscription?.cancel();
|
||||
await _methodChannel.invokeMethod('dispose');
|
||||
|
||||
await _playingController.close();
|
||||
await _completedController.close();
|
||||
await _bufferingController.close();
|
||||
await _positionController.close();
|
||||
await _durationController.close();
|
||||
await _bufferController.close();
|
||||
await _volumeController.close();
|
||||
await _rateController.close();
|
||||
await _tracksController.close();
|
||||
await _trackController.close();
|
||||
await _logController.close();
|
||||
await _errorController.close();
|
||||
await _audioDeviceController.close();
|
||||
await _audioDevicesController.close();
|
||||
await _playbackRestartController.close();
|
||||
await _backendSwitchedController.close();
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ class PlayerNative implements Player {
|
||||
final _audioDeviceController = StreamController<AudioDevice>.broadcast();
|
||||
final _audioDevicesController = StreamController<List<AudioDevice>>.broadcast();
|
||||
final _playbackRestartController = StreamController<void>.broadcast();
|
||||
// MPV handles all formats, so this stream never emits (only ExoPlayer needs fallback)
|
||||
final _backendSwitchedController = StreamController<void>.broadcast();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
bool _disposed = false;
|
||||
@@ -68,6 +70,7 @@ class PlayerNative implements Player {
|
||||
audioDevice: _audioDeviceController.stream,
|
||||
audioDevices: _audioDevicesController.stream,
|
||||
playbackRestart: _playbackRestartController.stream,
|
||||
backendSwitched: _backendSwitchedController.stream,
|
||||
);
|
||||
|
||||
_setupEventListener();
|
||||
@@ -656,5 +659,6 @@ class PlayerNative implements Player {
|
||||
await _audioDeviceController.close();
|
||||
await _audioDevicesController.close();
|
||||
await _playbackRestartController.close();
|
||||
await _backendSwitchedController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ class PlayerStreams {
|
||||
/// Stream that emits when playback restarts (first frame ready after load/seek).
|
||||
final Stream<void> playbackRestart;
|
||||
|
||||
/// Stream that emits when the native player backend switches (e.g., ExoPlayer to MPV).
|
||||
/// Only emitted on Android when ExoPlayer encounters an unsupported format.
|
||||
final Stream<void> backendSwitched;
|
||||
|
||||
const PlayerStreams({
|
||||
required this.playing,
|
||||
required this.completed,
|
||||
@@ -66,5 +70,6 @@ class PlayerStreams {
|
||||
required this.audioDevice,
|
||||
required this.audioDevices,
|
||||
required this.playbackRestart,
|
||||
required this.backendSwitched,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
int _maxVolume = 100;
|
||||
bool _enableDiscordRPC = false;
|
||||
bool _matchContentFrameRate = false;
|
||||
bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV
|
||||
|
||||
// Update checking state
|
||||
bool _isCheckingForUpdate = false;
|
||||
@@ -99,6 +100,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_maxVolume = _settingsService.getMaxVolume();
|
||||
_enableDiscordRPC = _settingsService.getEnableDiscordRPC();
|
||||
_matchContentFrameRate = _settingsService.getMatchContentFrameRate();
|
||||
_useExoPlayer = _settingsService.getUseExoPlayer();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -260,6 +262,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.play_circle_rounded, fill: 1),
|
||||
title: Text(t.settings.playerBackend),
|
||||
subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => _showPlayerBackendDialog(),
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const AppIcon(Symbols.hardware_rounded, fill: 1),
|
||||
title: Text(t.settings.hardwareDecoding),
|
||||
@@ -301,15 +311,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.tune_rounded, fill: 1),
|
||||
title: Text(t.mpvConfig.title),
|
||||
subtitle: Text(t.mpvConfig.description),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen()));
|
||||
},
|
||||
),
|
||||
// MPV Config is only available when using MPV player backend
|
||||
if (!Platform.isAndroid || !_useExoPlayer)
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.tune_rounded, fill: 1),
|
||||
title: Text(t.mpvConfig.title),
|
||||
subtitle: Text(t.mpvConfig.description),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
|
||||
title: Text(t.settings.smallSkipDuration),
|
||||
@@ -1059,6 +1071,53 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlayerBackendDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(t.settings.playerBackend),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: AppIcon(
|
||||
_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
title: Text(t.settings.exoPlayer),
|
||||
subtitle: Text(t.settings.exoPlayerDescription),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_useExoPlayer = true;
|
||||
});
|
||||
await _settingsService.setUseExoPlayer(true);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: AppIcon(
|
||||
!_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
|
||||
fill: 1,
|
||||
),
|
||||
title: Text(t.settings.mpv),
|
||||
subtitle: Text(t.settings.mpvDescription),
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_useExoPlayer = false;
|
||||
});
|
||||
await _settingsService.setUseExoPlayer(false);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showKeyboardShortcutsDialog() {
|
||||
if (_keyboardService == null) return;
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
StreamSubscription<Tracks>? _trackLoadingSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
StreamSubscription<void>? _playbackRestartSubscription;
|
||||
StreamSubscription<void>? _backendSwitchedSubscription;
|
||||
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
bool _isDisposingForNavigation = false;
|
||||
bool _waitingForExternalSubsTrackSelection = false;
|
||||
@@ -290,9 +291,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
|
||||
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
|
||||
final useExoPlayer = settingsService.getUseExoPlayer();
|
||||
|
||||
// Create player
|
||||
player = Player();
|
||||
// Create player (on Android, uses ExoPlayer by default, MPV as fallback)
|
||||
player = Player(useExoPlayer: useExoPlayer);
|
||||
|
||||
await player!.setProperty('sub-ass', 'yes'); // Enable libass
|
||||
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||
@@ -402,6 +404,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Listen to MPV errors
|
||||
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
||||
|
||||
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
||||
if (Platform.isAndroid && useExoPlayer) {
|
||||
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||
}
|
||||
|
||||
// Listen to buffering state
|
||||
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
||||
_isBuffering.value = isBuffering;
|
||||
@@ -1069,6 +1076,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_trackLoadingSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_playbackRestartSubscription?.cancel();
|
||||
_backendSwitchedSubscription?.cancel();
|
||||
|
||||
// Cancel auto-play timer
|
||||
_autoPlayTimer?.cancel();
|
||||
@@ -1204,6 +1212,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
appLogger.e('[MPV ERROR] $error');
|
||||
}
|
||||
|
||||
/// Handle notification when native player switched from ExoPlayer to MPV
|
||||
void _onBackendSwitched() {
|
||||
appLogger.i('Player backend switched from ExoPlayer to MPV (native fallback)');
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.switchingToCompatiblePlayer), duration: const Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// OS Media Controls Integration
|
||||
|
||||
/// Wrapper method to update media controls playback state
|
||||
|
||||
@@ -64,6 +64,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static const String _keyMatchContentFrameRate = 'match_content_frame_rate';
|
||||
static const String _keyDefaultPlaybackSpeed = 'default_playback_speed';
|
||||
static const String _keyAutoPlayNextEpisode = 'auto_play_next_episode';
|
||||
static const String _keyUseExoPlayer = 'use_exoplayer';
|
||||
|
||||
SettingsService._();
|
||||
|
||||
@@ -981,6 +982,16 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
return prefs.getBool(_keyAutoPlayNextEpisode) ?? true; // Default enabled
|
||||
}
|
||||
|
||||
// Use ExoPlayer on Android (default: true)
|
||||
// When false, uses MPV as the player backend
|
||||
Future<void> setUseExoPlayer(bool enabled) async {
|
||||
await prefs.setBool(_keyUseExoPlayer, enabled);
|
||||
}
|
||||
|
||||
bool getUseExoPlayer() {
|
||||
return prefs.getBool(_keyUseExoPlayer) ?? true; // Default: ExoPlayer
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
Future<void> resetAllSettings() async {
|
||||
await Future.wait([
|
||||
@@ -1025,6 +1036,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
prefs.remove(_keyMatchContentFrameRate),
|
||||
prefs.remove(_keyDefaultPlaybackSpeed),
|
||||
prefs.remove(_keyAutoPlayNextEpisode),
|
||||
prefs.remove(_keyUseExoPlayer),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user