feat: exoplayer

This commit is contained in:
edde746
2026-01-23 18:29:35 +01:00
parent 12c4c389d1
commit 943f7dab08
29 changed files with 2572 additions and 41 deletions
+19
View File
@@ -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")
}
+2 -1
View File
@@ -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) {
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "스타일 옵션",
+7 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+14 -2
View File
@@ -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',
+26 -2
View File
@@ -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.',
+14 -2
View File
@@ -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.',
+14 -2
View File
@@ -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',
+14 -2
View File
@@ -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' => '세션을 종료 하시겠습니까?',
+14 -2
View File
@@ -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',
+14 -2
View File
@@ -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',
+14 -2
View File
@@ -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' => '主持会话',
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "样式选项",
+18 -3
View File
@@ -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) {
+637
View File
@@ -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();
}
}
+4
View File
@@ -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();
}
}
+5
View File
@@ -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,
});
}
+68 -9
View File
@@ -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;
+21 -2
View File
@@ -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
+12
View File
@@ -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),
]);
}