Merge remote-tracking branch 'upstream/main' into feature/expand-episodes

This commit is contained in:
Micah Morrison
2026-02-27 16:47:51 -05:00
150 changed files with 3800 additions and 2780 deletions
+27
View File
@@ -0,0 +1,27 @@
name: SonarQube
on:
workflow_dispatch:
jobs:
sonarqube:
name: SonarQube
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Install dependencies
run: flutter pub get
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v6
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1
View File
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
/debug-info/
# Symbolication related
app.*.symbols
+2 -2
View File
@@ -1,6 +1,6 @@
cask "plezy" do
version "1.21.0"
sha256 "aae91bb766d2726a79a87b124a070c402106a627feacfd34b840ad14a43fd00a"
version "1.21.3"
sha256 "81c643d9d67ed71ffa90d5ac36da6ef23fd0f71d3ae69f690cbe7710d511d74e"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
+1 -1
View File
@@ -41,7 +41,7 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
### 🎬 Playback
- Wide codec support (HEVC, AV1, VP9, and more)
- HDR and Dolby Vision (iOS, macOS, Windows)
- HDR and Dolby Vision (not Linux)
- Full ASS/SSA subtitle support
- Audio and subtitle preferences synced with Plex profile
- Progress sync and resume
@@ -9,6 +9,8 @@ import android.app.PictureInPictureParams
import android.content.Context
import android.content.res.Configuration
import android.util.Rational
import android.view.KeyEvent
import android.view.ViewGroup
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.RenderMode
@@ -24,11 +26,36 @@ class MainActivity : FlutterActivity() {
private val PIP_CHANNEL = "app.plezy/pip"
private val EXTERNAL_PLAYER_CHANNEL = "app.plezy/external_player"
private val THEME_CHANNEL = "app.plezy/theme"
private var watchNextPlugin: WatchNextPlugin? = null
private var cachedFlutterView: android.view.View? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Apply persisted theme color to the window background before anything
// else renders. This prevents a white flash between the native splash
// screen and Flutter's first frame for non-default themes (e.g. OLED).
val prefs = getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
val savedTheme = prefs.getString("splash_theme", null)
if (savedTheme != null) {
val color = when (savedTheme) {
"oled" -> android.graphics.Color.BLACK
"dark" -> android.graphics.Color.parseColor("#0E0F12")
"light" -> android.graphics.Color.parseColor("#F7F7F8")
else -> null
}
if (color != null) {
window.decorView.setBackgroundColor(color)
}
}
super.onCreate(savedInstanceState)
// Disable the Android splash screen fade-out animation to avoid
// a flicker before Flutter draws its first frame.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView -> splashScreenView.remove() }
}
// Disable Android's default focus highlight ring that appears when using
// D-pad navigation so the Flutter UI can render its own focus state.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -39,6 +66,37 @@ class MainActivity : FlutterActivity() {
handleWatchNextIntent(intent)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
// Ensure FlutterView has focus for DPAD events so they reach Flutter's
// key event system. Without this, Android's native focus navigation can
// consume DPAD direction events (especially from the Google TV virtual
// remote) before they reach Flutter.
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_CENTER -> {
val fv = cachedFlutterView ?: findFlutterView(window.decorView)?.also { cachedFlutterView = it }
if (fv != null && !fv.hasFocus()) {
fv.requestFocus()
}
}
}
return super.dispatchKeyEvent(event)
}
private fun findFlutterView(view: android.view.View): android.view.View? {
if (view.javaClass.name.contains("FlutterView")) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
val found = findFlutterView(view.getChildAt(i))
if (found != null) return found
}
}
return null
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle Watch Next deep link when app is already running
@@ -119,6 +177,41 @@ class MainActivity : FlutterActivity() {
}
}
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"setSplashTheme" -> {
val mode = call.argument<String>("mode")
// Persist for next cold start & update window background now
getSharedPreferences("plezy_prefs", Context.MODE_PRIVATE)
.edit().putString("splash_theme", mode).apply()
val color = when (mode) {
"oled" -> android.graphics.Color.BLACK
"dark" -> android.graphics.Color.parseColor("#0E0F12")
"light" -> android.graphics.Color.parseColor("#F7F7F8")
else -> null
}
if (color != null) {
window.decorView.setBackgroundColor(color)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val themeId = when (mode) {
"dark" -> R.style.SplashTheme_Dark
"oled" -> R.style.SplashTheme_Oled
"light" -> R.style.SplashTheme_Light
"system" -> android.content.res.Resources.ID_NULL
else -> android.content.res.Resources.ID_NULL
}
splashScreen.setSplashScreenTheme(themeId)
}
result.success(true)
}
else -> result.notImplemented()
}
}
// Register Watch Next plugin and keep reference for deep link handling
watchNextPlugin = WatchNextPlugin()
flutterEngine.plugins.add(watchNextPlugin!!)
@@ -2,9 +2,7 @@ package com.edde746.plezy.exoplayer
import android.app.Activity
import android.app.ActivityManager
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
@@ -90,6 +88,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var exoPlayer: ExoPlayer? = null
private var trackSelector: DefaultTrackSelector? = null
private var tunnelingDisabledForCodec: Boolean = false
private var pendingStartPositionMs: Long = 0L
var delegate: ExoPlayerDelegate? = null
var isInitialized: Boolean = false
private set
@@ -105,9 +105,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var hasAudioFocus: Boolean = false
private var wasPlayingBeforeFocusLoss: Boolean = false
// Memory pressure detection
private var memoryCallback: ComponentCallbacks2? = null
// Track state for event emission
private var lastPosition: Long = 0
private var lastDuration: Long = 0
@@ -356,16 +353,21 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
} else {
// Scale buffer to available memory to reduce hardware decoder pressure
when {
availableMB < 512 -> 50 * 1024 * 1024
availableMB < 1024 -> 75 * 1024 * 1024
else -> 150 * 1024 * 1024
availableMB <= 512 -> 30 * 1024 * 1024
availableMB <= 1024 -> 50 * 1024 * 1024
availableMB <= 2048 -> 60 * 1024 * 1024
else -> 130 * 1024 * 1024
}
}
val loadControl = DefaultLoadControl.Builder().apply {
setTargetBufferBytes(targetBufferBytes)
setPrioritizeTimeOverSizeThresholds(false)
setBufferDurationsMs(15_000, 30_000, 2_500, 5_000)
if (availableMB <= 2048) {
setBufferDurationsMs(15_000, 50_000, 2_500, 5_000)
} else {
setBufferDurationsMs(30_000, 60_000, 2_500, 5_000)
}
}.build()
Log.d(TAG, "Buffer: ${targetBufferBytes / 1024 / 1024}MB limit, available=${availableMB}MB")
@@ -401,22 +403,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
}
}
// Register memory pressure listener to detect impending OOM
memoryCallback = object : ComponentCallbacks2 {
override fun onTrimMemory(level: Int) {
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
Log.w(TAG, "TRIM_MEMORY level $level - critical memory pressure")
delegate?.onEvent("memory-pressure", mapOf("level" to "critical"))
}
}
override fun onConfigurationChanged(newConfig: Configuration) {}
override fun onLowMemory() {
Log.w(TAG, "onLowMemory - system-wide memory pressure")
delegate?.onEvent("memory-pressure", mapOf("level" to "critical"))
}
}
activity.registerComponentCallbacks(memoryCallback)
// Start position update loop
startPositionUpdates()
@@ -514,6 +500,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
delegate?.onPropertyChange("paused-for-cache", true)
}
Player.STATE_READY -> {
// Restore start position if it was lost during track reselection
// (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
if (pendingStartPositionMs > 0L) {
val currentPos = exoPlayer?.currentPosition ?: 0L
if (currentPos < 1000L) {
Log.w(TAG, "Position lost during init (at ${currentPos}ms, expected ${pendingStartPositionMs}ms) — restoring")
exoPlayer?.seekTo(pendingStartPositionMs)
}
pendingStartPositionMs = 0L
}
delegate?.onPropertyChange("paused-for-cache", false)
delegate?.onEvent("playback-restart", null)
emitTrackList()
@@ -527,6 +523,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
Log.d(TAG, "onTracksChanged")
evaluateAudioCodecForTunneling()
emitTrackList()
}
@@ -711,6 +708,69 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
delegate?.onPropertyChange("track-list", trackList)
}
// Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg)
private fun hasHardwareAudioDecoder(mimeType: String): Boolean {
try {
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mimeType, ignoreCase = true)) {
val name = info.name
if (!name.startsWith("OMX.google.") &&
!name.startsWith("c2.android.") &&
!name.contains(".sw.") &&
!name.startsWith("c2.ffmpeg.")) {
Log.d(TAG, "Found hardware audio decoder for $mimeType: $name")
return true
}
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}")
}
Log.d(TAG, "No hardware audio decoder for $mimeType — FFmpeg will handle it")
return false
}
private fun updateTunnelingState() {
val selector = trackSelector ?: return
val player = exoPlayer ?: return
val currentSpeed = player.playbackParameters.speed
val shouldTunnel = (currentSpeed == 1f) && !tunnelingDisabledForCodec
val currentTunneling = selector.parameters.tunnelingEnabled
if (shouldTunnel == currentTunneling) return // No change needed
Log.d(TAG, "updateTunnelingState: tunneling $currentTunneling -> $shouldTunnel")
selector.setParameters(
selector.buildUponParameters()
.setTunnelingEnabled(shouldTunnel)
)
// Track reselection from setParameters() can reset position during initial load.
// Restore the pending start position if it hasn't been consumed yet.
if (pendingStartPositionMs > 0L) {
player.seekTo(pendingStartPositionMs)
}
}
private fun evaluateAudioCodecForTunneling() {
val player = exoPlayer ?: return
val selectedAudioGroup = player.currentTracks.groups.firstOrNull {
it.type == C.TRACK_TYPE_AUDIO && it.isSelected
} ?: return
val format = selectedAudioGroup.mediaTrackGroup.getFormat(0)
val mimeType = format.sampleMimeType ?: return
val newDisabled = !hasHardwareAudioDecoder(mimeType)
if (newDisabled != tunnelingDisabledForCodec) {
tunnelingDisabledForCodec = newDisabled
Log.i(TAG, "Audio codec ${format.codecs} ($mimeType): tunneling ${if (newDisabled) "DISABLED" else "enabled"}")
updateTunnelingState()
}
}
// Public API
fun open(uri: String, headers: Map<String, String>?, startPositionMs: Long, autoPlay: Boolean, isLive: Boolean = false) {
@@ -719,6 +779,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
currentMediaUri = uri
currentHeaders = headers
externalSubtitles.clear()
tunnelingDisabledForCodec = false
pendingStartPositionMs = startPositionMs
if (isLive) {
// Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells
@@ -796,14 +858,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
fun setPlaybackSpeed(speed: Float) {
val clampedSpeed = speed.coerceIn(0.25f, 4f)
exoPlayer?.setPlaybackSpeed(clampedSpeed)
// Disable tunneling when speed != 1.0 — tunneled playback bypasses
// ExoPlayer's audio processors, silently ignoring speed changes.
trackSelector?.setParameters(
trackSelector!!.buildUponParameters()
.setTunnelingEnabled(clampedSpeed == 1f)
)
updateTunnelingState()
delegate?.onPropertyChange("speed", speed.toDouble())
}
@@ -820,9 +875,23 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val audioGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
if (trackIndex >= 0 && trackIndex < audioGroups.size) {
val group = audioGroups[trackIndex]
// Pre-evaluate the new track's codec for tunneling before applying the override,
// so tunneling state is set correctly in the same parameter update.
val format = group.mediaTrackGroup.getFormat(0)
val mimeType = format.sampleMimeType
if (mimeType != null) {
tunnelingDisabledForCodec = !hasHardwareAudioDecoder(mimeType)
Log.i(TAG, "Audio track switch to ${format.codecs} ($mimeType): tunneling ${if (tunnelingDisabledForCodec) "DISABLED" else "enabled"}")
}
val currentSpeed = player.playbackParameters.speed
val shouldTunnel = (currentSpeed == 1f) && !tunnelingDisabledForCodec
selector.parameters = selector.buildUponParameters()
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.setTunnelingEnabled(shouldTunnel)
.build()
delegate?.onPropertyChange("aid", trackId)
@@ -1302,9 +1371,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
abandonAudioFocus()
audioManager = null
memoryCallback?.let { activity.unregisterComponentCallbacks(it) }
memoryCallback = null
tunnelingDisabledForCodec = false
pendingStartPositionMs = 0L
exoPlayer?.clearVideoSurface()
exoPlayer?.removeListener(this)
exoPlayer?.release()
@@ -29,6 +29,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private var playerCore: ExoPlayerCore? = null
private var mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private val nameToId = mutableMapOf<String, Int>()
@@ -66,6 +67,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
@@ -169,14 +171,12 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
if (usingMpvFallback) {
mpvCore?.dispose()
mpvCore = null
} else {
playerCore?.dispose()
playerCore = null
}
playerCore?.dispose()
playerCore = null
mpvCore?.dispose()
mpvCore = null
usingMpvFallback = false
fallbackInProgress = false
Log.d(TAG, "Disposed")
result.success(null)
} ?: result.success(null)
@@ -571,7 +571,13 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
positionMs: Long,
errorMessage: String
): Boolean {
if (usingMpvFallback || fallbackInProgress) {
Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request")
return true
}
val currentActivity = activity ?: return false
fallbackInProgress = true
Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage")
@@ -580,69 +586,74 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
// Dispose ExoPlayer
playerCore?.dispose()
playerCore = null
mpvCore?.dispose()
mpvCore = 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")
// Forward user's buffer config to MPV fallback
configuredBufferSizeBytes?.let { bytes ->
if (bytes > 0) {
mpvCore?.setProperty("demuxer-max-bytes", bytes.toString())
mpvCore?.initialize { success ->
if (!success) {
fallbackInProgress = false
Log.e(TAG, "Failed to initialize MPV fallback")
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage"))
return@initialize
}
usingMpvFallback = true
fallbackInProgress = false
// Configure basic MPV properties for Plex playback
mpvCore?.setProperty("hwdec", "auto")
mpvCore?.setProperty("vo", "gpu")
mpvCore?.setProperty("ao", "audiotrack")
// Forward user's buffer config to MPV fallback
configuredBufferSizeBytes?.let { bytes ->
if (bytes > 0) {
mpvCore?.setProperty("demuxer-max-bytes", bytes.toString())
}
}
// 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(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "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")
}
// 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(",")
// Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads)
val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri
mpvCore?.command(arrayOf("loadfile", mpvUri, "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) {
fallbackInProgress = false
Log.e(TAG, "Failed to switch to MPV fallback", e)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}"))
}
@@ -40,6 +40,9 @@ class MpvPlayerCore(private val activity: Activity) :
companion object {
private const val TAG = "MpvPlayerCore"
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
// Guards MPVLib.create/destroy which share global native state
private val mpvLock = Object()
}
private var surfaceView: SurfaceView? = null
@@ -47,6 +50,9 @@ class MpvPlayerCore(private val activity: Activity) :
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? =
null
private var voInUse: String = "gpu"
@Volatile private var nativeReady: Boolean = false
@Volatile private var disposing: Boolean = false
private var pendingSurface: Surface? = null
var delegate: MpvPlayerDelegate? = null
var isInitialized: Boolean = false
private set
@@ -178,13 +184,17 @@ class MpvPlayerCore(private val activity: Activity) :
}
}
fun initialize(): Boolean {
fun initialize(onResult: (Boolean) -> Unit) {
if (isInitialized) {
Log.d(TAG, "Already initialized")
return true
onResult(true)
return
}
try {
disposing = false
pendingSurface = null
// Initialize AudioManager for audio focus handling
audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -250,25 +260,62 @@ class MpvPlayerCore(private val activity: Activity) :
Log.d(TAG, "SurfaceView added to content view")
// Initialize MPVLib
MPVLib.create(activity.applicationContext)
// Native MPVLib init on background thread — waits for any
// in-flight destroy to finish without blocking the UI thread.
val ctx = activity.applicationContext
Thread {
try {
synchronized(mpvLock) {
if (disposing) {
handler.post { onResult(false) }
return@Thread
}
MPVLib.create(ctx)
setupMpvDefaults()
MPVLib.init()
nativeReady = true
}
handler.post {
if (disposing) {
if (nativeReady) {
Thread {
synchronized(mpvLock) {
try {
MPVLib.destroy()
} catch (_: Exception) {
} finally {
nativeReady = false
}
}
}.start()
}
onResult(false)
return@post
}
// Configure MPV defaults
setupMpvDefaults()
MPVLib.addObserver(this)
MPVLib.addLogObserver(this)
isInitialized = true
// Initialize MPV
MPVLib.init()
// surfaceCreated can fire before MPV init finishes.
// Defer attaching the surface until native init is ready.
pendingSurface?.takeIf { it.isValid }?.let {
attachSurfaceInternal(it)
}
pendingSurface = null
// Register event and log observers
MPVLib.addObserver(this)
MPVLib.addLogObserver(this)
isInitialized = true
Log.d(TAG, "Initialized successfully")
return true
Log.d(TAG, "Initialized successfully")
onResult(true)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize native: ${e.message}", e)
nativeReady = false
handler.post { onResult(false) }
}
}.start()
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
return false
onResult(false)
}
}
@@ -350,25 +397,59 @@ class MpvPlayerCore(private val activity: Activity) :
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d(TAG, "Surface created")
MPVLib.attachSurface(holder.surface)
MPVLib.setOptionString("force-window", "yes")
// Restore video output after surface is available
MPVLib.setPropertyString("vo", voInUse)
if (disposing) return
val surface = holder.surface
if (!nativeReady) {
pendingSurface = surface
Log.d(TAG, "Deferring surface attach until MPV native init completes")
return
}
attachSurfaceInternal(surface)
// Reassert overlay order whenever the surface is recreated
ensureFlutterOverlayOnTop()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.d(TAG, "Surface changed: ${width}x${height}")
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
if (!nativeReady || disposing) return
try {
MPVLib.setPropertyString("android-surface-size", "${width}x${height}")
} catch (e: Exception) {
Log.w(TAG, "Failed to apply surface size to MPV", e)
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d(TAG, "Surface destroyed")
pendingSurface = null
if (!nativeReady || disposing) return
detachSurfaceInternal()
}
private fun attachSurfaceInternal(surface: Surface) {
if (!nativeReady || disposing || !surface.isValid) return
try {
MPVLib.attachSurface(surface)
MPVLib.setOptionString("force-window", "yes")
// Restore video output after surface is available
MPVLib.setPropertyString("vo", voInUse)
} catch (e: Exception) {
Log.w(TAG, "Failed to attach MPV surface", e)
}
}
private fun detachSurfaceInternal() {
if (!nativeReady) return
// Disable video output before detaching (like mpv-android)
MPVLib.setPropertyString("vo", "null")
MPVLib.setOptionString("force-window", "no")
MPVLib.detachSurface()
try {
MPVLib.setPropertyString("vo", "null")
MPVLib.setOptionString("force-window", "no")
MPVLib.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach MPV surface", e)
}
}
// MPVLib.EventObserver
@@ -703,6 +784,8 @@ class MpvPlayerCore(private val activity: Activity) :
// Cleanup
fun dispose() {
if (disposing) return
disposing = true
Log.d(TAG, "Disposing")
// Shutdown command executor
@@ -715,8 +798,15 @@ class MpvPlayerCore(private val activity: Activity) :
abandonAudioFocus()
audioManager = null
MPVLib.removeObserver(this)
MPVLib.removeLogObserver(this)
if (nativeReady) {
try {
MPVLib.removeObserver(this)
MPVLib.removeLogObserver(this)
} catch (e: Exception) {
Log.w(TAG, "Failed to remove MPV observers during dispose", e)
}
detachSurfaceInternal()
}
overlayLayoutListener?.let { listener ->
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
@@ -737,10 +827,25 @@ class MpvPlayerCore(private val activity: Activity) :
}
surfaceContainer = null
surfaceView = null
MPVLib.destroy()
pendingSurface = null
isInitialized = false
Log.d(TAG, "Disposed")
// Run native destroy on background thread to avoid ANR —
// MPVLib.destroy() blocks on pthread_cond_wait while mpv's
// internal threads (lua, demux, vo) shut down.
if (nativeReady) {
Thread {
synchronized(mpvLock) {
try {
MPVLib.destroy()
} catch (e: Exception) {
Log.w(TAG, "MPV destroy failed", e)
} finally {
nativeReady = false
}
}
Log.d(TAG, "Disposed (native)")
}.start()
}
}
}
@@ -125,14 +125,14 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
playerCore = MpvPlayerCore(currentActivity).apply {
delegate = this@MpvPlayerPlugin
}
val success = playerCore?.initialize() ?: false
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
playerCore?.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
playerCore?.initialize { success ->
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
playerCore?.setVisible(false)
Log.d(TAG, "Initialized: $success")
result.success(success)
} ?: result.success(false)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
result.error("INIT_FAILED", e.message, null)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<vector xmlns:aapt="http://schemas.android.com/aapt"
xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="911.63"
android:viewportWidth="911.63">
<group android:translateX="276.65" android:translateY="223.88">
<path android:pathData="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-0.67-4.5-4.86-4.2-12.45 0.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95,0.66,4.5,4.87,4.21,12.46-0.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-0.66s-4.2-12.46,0.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95,0.67,4.5,4.86,4.21,12.45-0.66,16.95Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="22.67"
android:startY="425.72"
android:endX="201.83"
android:endY="115.4">
<item android:color="#FFAB543A" android:offset="0.0" />
<item android:color="#FFFF7E57" android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
</group>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="108dp"
android:width="108dp"
android:viewportHeight="911.63"
android:viewportWidth="911.63">
<group android:translateX="276.65" android:translateY="223.88">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-0.67-4.5-4.86-4.2-12.45 0.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95,0.66,4.5,4.87,4.21,12.46-0.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-0.66s-4.2-12.46,0.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95,0.67,4.5,4.86,4.21,12.45-0.66,16.95Z" />
</group>
</vector>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_launcher_foreground" />
@@ -1,14 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<monochrome>
<inset
android:drawable="@mipmap/ic_launcher_monochrome"
android:inset="16%" />
</monochrome>
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1a1a1a</color>
<color name="splash_background">#0E0F12</color>
<color name="splash_dark">#0E0F12</color>
<color name="splash_oled">#000000</color>
<color name="splash_light">#F7F7F8</color>
</resources>
@@ -2,6 +2,7 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
@@ -13,9 +14,31 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Splash theme variants for setSplashScreenTheme -->
<style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
@@ -1,4 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ffffff</color>
<color name="splash_background">#F7F7F8</color>
<color name="splash_dark">#0E0F12</color>
<color name="splash_oled">#000000</color>
<color name="splash_light">#F7F7F8</color>
</resources>
+24 -1
View File
@@ -2,6 +2,7 @@
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
@@ -13,9 +14,31 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowBackground">@color/splash_background</item>
<!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation -->
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<!-- Splash theme variants for setSplashScreenTheme -->
<style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_dark</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_oled</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_light</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
<style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:defaultFocusHighlightEnabled">false</item>
<item name="android:colorControlHighlight">@android:color/transparent</item>
</style>
</resources>
+3 -2
View File
@@ -19,7 +19,7 @@ platform :android do
UI.user_error!("Could not extract version from pubspec.yaml")
end
debug_info_dir = "./build/debug-info/#{version_name}+#{version_code}"
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
# Build the Flutter app
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --obfuscate --split-debug-info=#{debug_info_dir}/aab")
@@ -40,7 +40,8 @@ platform :android do
"../build/app/outputs/flutter-apk/app-arm64-v8a-release.apk"
],
overwrite_upload: true,
overwrite_upload_mode: 'reuse'
overwrite_upload_mode: 'reuse',
changes_not_sent_for_review: true
)
end
end
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-276.65 -223.88 911.63 911.63"><defs><linearGradient id="linear-gradient" x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#linear-gradient)" d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

+1 -1
View File
@@ -783,7 +783,7 @@
repositoryURL = "https://github.com/edde746/MPVKit";
requirement = {
kind = revision;
revision = 0d0931fbbb25a3483a7edb46babd3f2f55abeefc;
revision = 2e887368b44ce1dc9e1649e7757ec62c2564e792;
};
};
/* End XCRemoteSwiftPackageReference section */
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "0d0931fbbb25a3483a7edb46babd3f2f55abeefc"
"revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792"
}
}
],
@@ -6,7 +6,7 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/edde746/MPVKit",
"state" : {
"revision" : "0d0931fbbb25a3483a7edb46babd3f2f55abeefc"
"revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792"
}
}
],
+184
View File
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../widgets/app_icon.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
/// Describes a single action button for use in [FocusableActionBar].
class FocusableAction {
/// Icon to display. Ignored when [child] is provided.
final IconData icon;
/// Icon color. Ignored when [child] is provided.
final Color? iconColor;
final String? tooltip;
final VoidCallback? onPressed;
/// Optional custom child widget placed inside the focus container.
/// Overrides the default [IconButton] built from [icon]/[tooltip]/[onPressed].
final Widget? child;
const FocusableAction({
this.icon = Icons.circle,
this.iconColor,
this.tooltip,
this.onPressed,
this.child,
});
}
/// A row of focusable action buttons for app bar [actions:].
///
/// Manages focus nodes, left/right D-pad navigation between buttons,
/// and the standard white-alpha background focus indicator internally.
///
/// Returns a single [Row] widget — place it inside the `actions:` list:
/// ```dart
/// CustomAppBar(
/// title: Text('Title'),
/// actions: [
/// FocusableActionBar(
/// actions: [
/// FocusableAction(icon: Symbols.refresh_rounded, onPressed: _refresh),
/// FocusableAction(icon: Symbols.upload_rounded, onPressed: _upload),
/// ],
/// ),
/// ],
/// )
/// ```
class FocusableActionBar extends StatefulWidget {
final List<FocusableAction> actions;
/// Called when the user presses down from any action button.
final VoidCallback? onNavigateDown;
/// Called when the user presses up from any action button.
final VoidCallback? onNavigateUp;
/// Called when the user presses left from the leftmost button.
final VoidCallback? onNavigateLeft;
/// Called when the user presses right from the rightmost button.
final VoidCallback? onNavigateRight;
/// Called when the user presses the back key while an action is focused.
final VoidCallback? onBack;
const FocusableActionBar({
super.key,
required this.actions,
this.onNavigateDown,
this.onNavigateUp,
this.onNavigateLeft,
this.onNavigateRight,
this.onBack,
});
@override
State<FocusableActionBar> createState() => FocusableActionBarState();
}
class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _focusStates;
/// Access a focus node by index (e.g. for external `requestFocus()` calls).
FocusNode getFocusNode(int index) => _focusNodes[index];
@override
void initState() {
super.initState();
_initNodes();
}
@override
void didUpdateWidget(FocusableActionBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.actions.length != widget.actions.length) {
_disposeNodes();
_initNodes();
}
}
void _initNodes() {
_focusNodes = List.generate(widget.actions.length, (i) => FocusNode(debugLabel: 'ActionBar[$i]'));
_focusStates = List.filled(widget.actions.length, false);
for (var i = 0; i < _focusNodes.length; i++) {
final idx = i;
_focusNodes[i].addListener(() {
final hasFocus = _focusNodes[idx].hasFocus;
if (_focusStates[idx] != hasFocus) {
setState(() => _focusStates[idx] = hasFocus);
}
});
}
}
void _disposeNodes() {
for (final node in _focusNodes) {
node.dispose();
}
}
@override
void dispose() {
_disposeNodes();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isKeyboard = InputModeTracker.isKeyboardMode(context);
final duration = FocusTheme.getAnimationDuration(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration),
],
);
}
Widget _buildButton(int index, bool isKeyboard, Duration duration) {
final action = widget.actions[index];
final isFocused = _focusStates[index];
final showFocus = isFocused && isKeyboard;
final opacity = isKeyboard && !isFocused ? 0.6 : 1.0;
return Focus(
focusNode: _focusNodes[index],
onKeyEvent: (node, event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
return dpadKeyHandler(
onSelect: action.onPressed,
onLeft: index > 0
? () => _focusNodes[index - 1].requestFocus()
: widget.onNavigateLeft,
onRight: index < _focusNodes.length - 1
? () => _focusNodes[index + 1].requestFocus()
: widget.onNavigateRight,
onDown: widget.onNavigateDown,
onUp: widget.onNavigateUp,
)(node, event);
},
child: AnimatedOpacity(
opacity: showFocus ? 1.0 : opacity,
duration: duration,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
child: action.child ??
IconButton(
icon: AppIcon(action.icon, fill: 1, color: action.iconColor),
tooltip: action.tooltip,
onPressed: action.onPressed,
),
),
),
);
}
}
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Möchtest du die App wirklich beenden?",
"dontAskAgain": "Nicht erneut fragen",
"exit": "Beenden",
"viewAll": "Alle anzeigen"
"viewAll": "Alle anzeigen",
"checkingNetwork": "Netzwerk wird geprüft...",
"refreshingServers": "Server werden aktualisiert...",
"loadingServers": "Server werden geladen...",
"connectingToServers": "Verbindung zu Servern...",
"startingOfflineMode": "Offlinemodus wird gestartet..."
},
"screens": {
"licenses": "Lizenzen",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
"showUnwatchedCountDescription": "Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an",
"hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen",
"hideSpoilersDescription": "Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden",
"playerBackend": "Player-Backend",
"exoPlayer": "ExoPlayer (Empfohlen)",
"exoPlayerDescription": "Android-nativer Player mit besserer Hardware-Unterstützung",
@@ -266,8 +273,9 @@
"goToSeason": "Zur Staffel",
"shufflePlay": "Zufallswiedergabe",
"fileInfo": "Dateiinfo",
"confirmDelete": "Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?",
"deleteMultipleWarning": "Mehrere Elemente können gelöscht werden.",
"deleteFromServer": "Vom Server löschen",
"confirmDelete": "Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.",
"deleteMultipleWarning": "Dies umfasst alle Episoden und deren Dateien.",
"mediaDeletedSuccessfully": "Medienelement erfolgreich gelöscht",
"mediaFailedToDelete": "Löschen des Medienelements fehlgeschlagen",
"rate": "Bewerten"
@@ -732,11 +740,8 @@
"minimize": "Minimieren"
},
"pairing": {
"recent": "Zuletzt",
"scan": "Scannen",
"manual": "Manuell",
"recentConnections": "Letzte Verbindungen",
"quickReconnect": "Schnell mit zuvor gekoppelten Geräten verbinden",
"pairWithDesktop": "Mit Desktop koppeln",
"enterSessionDetails": "Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.",
"cameraError": "Kamera konnte nicht gestartet werden: ${error}",
"scanInstruction": "Richte deine Kamera auf den QR-Code auf deinem Desktop",
"noRecentConnections": "Keine letzten Verbindungen",
"connectUsingManual": "Verbinde dich über die manuelle Eingabe, um loszulegen",
"invalidQrCode": "Ungültiges QR-Code-Format",
"removeRecentConnection": "Letzte Verbindung entfernen",
"removeConfirm": "\"${name}\" aus den letzten Verbindungen entfernen?",
"validationHostRequired": "Bitte Host-Adresse eingeben",
"validationHostFormat": "Format muss IP:Port sein (z.B. 192.168.1.100:48632)",
"validationSessionIdRequired": "Bitte Sitzungs-ID eingeben",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN muss 6 Ziffern haben",
"connectionTimedOut": "Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.",
"sessionNotFound": "Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.",
"failedToConnect": "Verbindung fehlgeschlagen: ${error}",
"failedToLoadRecent": "Letzte Sitzungen konnten nicht geladen werden: ${error}"
"failedToConnect": "Verbindung fehlgeschlagen: ${error}"
},
"remote": {
"disconnectConfirm": "Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?",
@@ -806,7 +806,8 @@
"subtitleSync": "Untertitel-Synchronisation",
"hdr": "HDR",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige"
"performanceOverlay": "Leistungsanzeige",
"audioPassthrough": "Audio-Durchleitung"
},
"externalPlayer": {
"title": "Externer Player",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Are you sure you want to exit?",
"dontAskAgain": "Don't ask again",
"exit": "Exit",
"viewAll": "View All"
"viewAll": "View All",
"checkingNetwork": "Checking network...",
"refreshingServers": "Refreshing servers...",
"loadingServers": "Loading servers...",
"connectingToServers": "Connecting to servers...",
"startingOfflineMode": "Starting offline mode..."
},
"screens": {
"licenses": "Licenses",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
"showUnwatchedCount": "Show Unwatched Count",
"showUnwatchedCountDescription": "Display unwatched episode count on shows and seasons",
"hideSpoilers": "Hide Spoilers for Unwatched Episodes",
"hideSpoilersDescription": "Blur thumbnails and hide descriptions for episodes you haven't watched yet",
"playerBackend": "Player Backend",
"exoPlayer": "ExoPlayer (Recommended)",
"exoPlayerDescription": "Android native player with better hardware support",
@@ -266,8 +273,9 @@
"goToSeason": "Go to season",
"shufflePlay": "Shuffle Play",
"fileInfo": "File Info",
"confirmDelete": "Are you sure you want to delete this item from your filesystem?",
"deleteMultipleWarning": "Multiple items may be deleted.",
"deleteFromServer": "Delete from server",
"confirmDelete": "This will permanently delete this media and its files from your server. This cannot be undone.",
"deleteMultipleWarning": "This includes all episodes and their files.",
"mediaDeletedSuccessfully": "Media item deleted successfully",
"mediaFailedToDelete": "Failed to delete media item",
"rate": "Rate"
@@ -732,11 +740,8 @@
"minimize": "Minimize"
},
"pairing": {
"recent": "Recent",
"scan": "Scan",
"manual": "Manual",
"recentConnections": "Recent Connections",
"quickReconnect": "Quickly reconnect to previously paired devices",
"pairWithDesktop": "Pair with Desktop",
"enterSessionDetails": "Enter the session details shown on your desktop device",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.",
"cameraError": "Could not start camera: ${error}",
"scanInstruction": "Point your camera at the QR code shown on your desktop",
"noRecentConnections": "No recent connections",
"connectUsingManual": "Connect to a device using Manual entry to get started",
"invalidQrCode": "Invalid QR code format",
"removeRecentConnection": "Remove Recent Connection",
"removeConfirm": "Remove \"${name}\" from recent connections?",
"validationHostRequired": "Please enter host address",
"validationHostFormat": "Format must be IP:port (e.g., 192.168.1.100:48632)",
"validationSessionIdRequired": "Please enter a session ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN must be 6 digits",
"connectionTimedOut": "Connection timed out. Please check the session ID and PIN.",
"sessionNotFound": "Could not find the session. Please check your credentials.",
"failedToConnect": "Failed to connect: ${error}",
"failedToLoadRecent": "Failed to load recent sessions: ${error}"
"failedToConnect": "Failed to connect: ${error}"
},
"remote": {
"disconnectConfirm": "Do you want to disconnect from the remote session?",
@@ -806,7 +806,8 @@
"subtitleSync": "Subtitle Sync",
"hdr": "HDR",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay"
"performanceOverlay": "Performance Overlay",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "External Player",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "¿Estás seguro de que quieres salir?",
"dontAskAgain": "No volver a preguntar",
"exit": "Salir",
"viewAll": "Ver todo"
"viewAll": "Ver todo",
"checkingNetwork": "Comprobando red...",
"refreshingServers": "Actualizando servidores...",
"loadingServers": "Cargando servidores...",
"connectingToServers": "Conectando a servidores...",
"startingOfflineMode": "Iniciando modo sin conexión..."
},
"screens": {
"licenses": "Licencias",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
"showUnwatchedCount": "Mostrar conteo de no vistos",
"showUnwatchedCountDescription": "Mostrar el conteo de episodios no vistos en series y temporadas",
"hideSpoilers": "Ocultar spoilers de episodios no vistos",
"hideSpoilersDescription": "Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto",
"playerBackend": "Reproductor",
"exoPlayer": "ExoPlayer (Recomendado)",
"exoPlayerDescription": "Reproductor nativo de Android con mejor soporte de hardware",
@@ -266,8 +273,9 @@
"goToSeason": "Ir a la temporada",
"shufflePlay": "Reproducción Aleatoria",
"fileInfo": "Información del Archivo",
"confirmDelete": "¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?",
"deleteMultipleWarning": "Es posible que se eliminen varios elementos.",
"deleteFromServer": "Eliminar del servidor",
"confirmDelete": "Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.",
"deleteMultipleWarning": "Esto incluye todos los episodios y sus archivos.",
"mediaDeletedSuccessfully": "Elemento multimedia eliminado con éxito",
"mediaFailedToDelete": "Error al eliminar el elemento multimedia",
"rate": "Calificar"
@@ -732,11 +740,8 @@
"minimize": "Minimizar"
},
"pairing": {
"recent": "Recientes",
"scan": "Escanear",
"manual": "Manual",
"recentConnections": "Conexiones recientes",
"quickReconnect": "Reconectar rápidamente con dispositivos emparejados anteriormente",
"pairWithDesktop": "Emparejar con escritorio",
"enterSessionDetails": "Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.",
"cameraError": "No se pudo iniciar la cámara: ${error}",
"scanInstruction": "Apunta tu cámara al código QR que aparece en tu escritorio",
"noRecentConnections": "No hay conexiones recientes",
"connectUsingManual": "Conéctate a un dispositivo usando la entrada manual para empezar",
"invalidQrCode": "Formato de código QR no válido",
"removeRecentConnection": "Eliminar conexión reciente",
"removeConfirm": "¿Eliminar \"${name}\" de las conexiones recientes?",
"validationHostRequired": "Por favor, introduce la dirección del host",
"validationHostFormat": "El formato debe ser IP:puerto (ej., 192.168.1.100:48632)",
"validationSessionIdRequired": "Por favor, introduce un ID de sesión",
@@ -763,8 +764,7 @@
"validationPinLength": "El PIN debe tener 6 dígitos",
"connectionTimedOut": "Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.",
"sessionNotFound": "No se encontró la sesión. Verifica tus credenciales.",
"failedToConnect": "Error al conectar: ${error}",
"failedToLoadRecent": "Error al cargar sesiones recientes: ${error}"
"failedToConnect": "Error al conectar: ${error}"
},
"remote": {
"disconnectConfirm": "¿Quieres desconectarte de la sesión remota?",
@@ -806,7 +806,8 @@
"subtitleSync": "Sincronización de subtítulos",
"hdr": "HDR",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento"
"performanceOverlay": "Indicador de rendimiento",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "Reproductor externo",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Êtes-vous sûr de vouloir quitter ?",
"dontAskAgain": "Ne plus demander",
"exit": "Quitter",
"viewAll": "Tout afficher"
"viewAll": "Tout afficher",
"checkingNetwork": "Vérification du réseau...",
"refreshingServers": "Actualisation des serveurs...",
"loadingServers": "Chargement des serveurs...",
"connectingToServers": "Connexion aux serveurs...",
"startingOfflineMode": "Démarrage en mode hors-ligne..."
},
"screens": {
"licenses": "Licenses",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
"showUnwatchedCount": "Afficher le nombre non visionné",
"showUnwatchedCountDescription": "Afficher le nombre d'épisodes non visionnés pour les séries et saisons",
"hideSpoilers": "Masquer les spoilers des épisodes non vus",
"hideSpoilersDescription": "Flouter les miniatures et masquer les descriptions des épisodes que vous n'avez pas encore regardés",
"playerBackend": "Moteur de lecture",
"exoPlayer": "ExoPlayer (Recommandé)",
"exoPlayerDescription": "Lecteur natif Android avec meilleur support matériel",
@@ -266,8 +273,9 @@
"goToSeason": "Aller à la saison",
"shufflePlay": "Lecture aléatoire",
"fileInfo": "Informations sur le fichier",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?",
"deleteMultipleWarning": "Plusieurs éléments peuvent être supprimés.",
"deleteFromServer": "Supprimer du serveur",
"confirmDelete": "Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.",
"deleteMultipleWarning": "Cela inclut tous les épisodes et leurs fichiers.",
"mediaDeletedSuccessfully": "Élément média supprimé avec succès",
"mediaFailedToDelete": "Échec de la suppression de l'élément média",
"rate": "Noter"
@@ -732,11 +740,8 @@
"minimize": "Réduire"
},
"pairing": {
"recent": "Récents",
"scan": "Scanner",
"manual": "Manuel",
"recentConnections": "Connexions récentes",
"quickReconnect": "Reconnexion rapide aux appareils précédemment jumelés",
"pairWithDesktop": "Jumeler avec un bureau",
"enterSessionDetails": "Saisissez les détails de la session affichés sur votre appareil de bureau",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "L'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l'accès à la caméra dans les paramètres de votre appareil.",
"cameraError": "Impossible de démarrer la caméra : ${error}",
"scanInstruction": "Pointez votre caméra vers le QR code affiché sur votre bureau",
"noRecentConnections": "Aucune connexion récente",
"connectUsingManual": "Connectez-vous à un appareil via la saisie manuelle pour commencer",
"invalidQrCode": "Format de QR code invalide",
"removeRecentConnection": "Supprimer la connexion récente",
"removeConfirm": "Supprimer \"${name}\" des connexions récentes ?",
"validationHostRequired": "Veuillez saisir l'adresse de l'hôte",
"validationHostFormat": "Le format doit être IP:port (ex : 192.168.1.100:48632)",
"validationSessionIdRequired": "Veuillez saisir un ID de session",
@@ -763,8 +764,7 @@
"validationPinLength": "Le PIN doit contenir 6 chiffres",
"connectionTimedOut": "Délai de connexion expiré. Veuillez vérifier l'ID de session et le PIN.",
"sessionNotFound": "Session introuvable. Veuillez vérifier vos identifiants.",
"failedToConnect": "Échec de la connexion : ${error}",
"failedToLoadRecent": "Échec du chargement des sessions récentes : ${error}"
"failedToConnect": "Échec de la connexion : ${error}"
},
"remote": {
"disconnectConfirm": "Voulez-vous vous déconnecter de la session distante ?",
@@ -806,7 +806,8 @@
"subtitleSync": "Synchronisation des sous-titres",
"hdr": "HDR",
"audioOutput": "Sortie audio",
"performanceOverlay": "Superposition de performance"
"performanceOverlay": "Superposition de performance",
"audioPassthrough": "Audio Pass-Through"
},
"externalPlayer": {
"title": "Lecteur externe",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Sei sicuro di voler uscire?",
"dontAskAgain": "Non chiedere più",
"exit": "Esci",
"viewAll": "Mostra tutto"
"viewAll": "Mostra tutto",
"checkingNetwork": "Verifica rete...",
"refreshingServers": "Aggiornamento server...",
"loadingServers": "Caricamento server...",
"connectingToServers": "Connessione ai server...",
"startingOfflineMode": "Avvio modalità offline..."
},
"screens": {
"licenses": "Licenze",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
"showUnwatchedCount": "Mostra conteggio non visti",
"showUnwatchedCountDescription": "Mostra il numero di episodi non visti per serie e stagioni",
"hideSpoilers": "Nascondi spoiler per episodi non visti",
"hideSpoilersDescription": "Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato",
"playerBackend": "Motore di riproduzione",
"exoPlayer": "ExoPlayer (Consigliato)",
"exoPlayerDescription": "Lettore nativo Android con migliore supporto hardware",
@@ -266,8 +273,9 @@
"goToSeason": "Vai alla stagione",
"shufflePlay": "Riproduzione casuale",
"fileInfo": "Info sul file",
"confirmDelete": "Sei sicuro di voler eliminare questo elemento dal tuo filesystem?",
"deleteMultipleWarning": "Potrebbero essere eliminati più elementi.",
"deleteFromServer": "Elimina dal server",
"confirmDelete": "Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.",
"deleteMultipleWarning": "Questo include tutti gli episodi e i loro file.",
"mediaDeletedSuccessfully": "Elemento multimediale eliminato con successo",
"mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale",
"rate": "Valuta"
@@ -732,11 +740,8 @@
"minimize": "Riduci"
},
"pairing": {
"recent": "Recenti",
"scan": "Scansiona",
"manual": "Manuale",
"recentConnections": "Connessioni recenti",
"quickReconnect": "Riconnettiti rapidamente ai dispositivi associati in precedenza",
"pairWithDesktop": "Associa con desktop",
"enterSessionDetails": "Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "L'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l'accesso alla fotocamera nelle impostazioni del dispositivo.",
"cameraError": "Impossibile avviare la fotocamera: ${error}",
"scanInstruction": "Punta la fotocamera verso il QR code mostrato sul tuo desktop",
"noRecentConnections": "Nessuna connessione recente",
"connectUsingManual": "Connettiti a un dispositivo tramite inserimento manuale per iniziare",
"invalidQrCode": "Formato QR code non valido",
"removeRecentConnection": "Rimuovi connessione recente",
"removeConfirm": "Rimuovere \"${name}\" dalle connessioni recenti?",
"validationHostRequired": "Inserisci l'indirizzo host",
"validationHostFormat": "Il formato deve essere IP:porta (es. 192.168.1.100:48632)",
"validationSessionIdRequired": "Inserisci un ID sessione",
@@ -763,8 +764,7 @@
"validationPinLength": "Il PIN deve essere di 6 cifre",
"connectionTimedOut": "Connessione scaduta. Verifica l'ID sessione e il PIN.",
"sessionNotFound": "Sessione non trovata. Verifica le tue credenziali.",
"failedToConnect": "Connessione fallita: ${error}",
"failedToLoadRecent": "Impossibile caricare le sessioni recenti: ${error}"
"failedToConnect": "Connessione fallita: ${error}"
},
"remote": {
"disconnectConfirm": "Vuoi disconnetterti dalla sessione remota?",
@@ -806,7 +806,8 @@
"subtitleSync": "Sincronizzazione sottotitoli",
"hdr": "HDR",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni"
"performanceOverlay": "Overlay prestazioni",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "Lettore esterno",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "정말 종료하시겠습니까?",
"dontAskAgain": "다시 묻지 않기",
"exit": "종료",
"viewAll": "모두 보기"
"viewAll": "모두 보기",
"checkingNetwork": "네트워크 확인 중...",
"refreshingServers": "서버 새로고침 중...",
"loadingServers": "서버 로딩 중...",
"connectingToServers": "서버 연결 중...",
"startingOfflineMode": "오프라인 모드 시작 중..."
},
"screens": {
"licenses": "라이선스",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
"showUnwatchedCount": "미시청 수 표시",
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다",
"playerBackend": "플레이어 백엔드",
"exoPlayer": "ExoPlayer (권장)",
"exoPlayerDescription": "더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어",
@@ -266,8 +273,9 @@
"goToSeason": "시즌으로 이동",
"shufflePlay": "무작위 재생",
"fileInfo": "파일 정보",
"confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?",
"deleteMultipleWarning": "여러 항목이 삭제될습니다.",
"deleteFromServer": "서버에서 삭제",
"confirmDelete": "이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.",
"deleteMultipleWarning": "모든 에피소드와 파일이 포함됩니다.",
"mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다",
"mediaFailedToDelete": "미디어 항목 삭제 실패",
"rate": "평가"
@@ -732,11 +740,8 @@
"minimize": "최소화"
},
"pairing": {
"recent": "최근",
"scan": "스캔",
"manual": "수동",
"recentConnections": "최근 연결",
"quickReconnect": "이전에 페어링한 기기에 빠르게 재연결",
"pairWithDesktop": "데스크톱과 페어링",
"enterSessionDetails": "데스크톱 기기에 표시된 세션 정보를 입력하세요",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.",
"cameraError": "카메라를 시작할 수 없습니다: ${error}",
"scanInstruction": "데스크톱에 표시된 QR 코드에 카메라를 향하세요",
"noRecentConnections": "최근 연결 없음",
"connectUsingManual": "수동 입력으로 기기에 연결하여 시작하세요",
"invalidQrCode": "유효하지 않은 QR 코드 형식",
"removeRecentConnection": "최근 연결 삭제",
"removeConfirm": "\"${name}\"을(를) 최근 연결에서 삭제하시겠습니까?",
"validationHostRequired": "호스트 주소를 입력하세요",
"validationHostFormat": "IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)",
"validationSessionIdRequired": "세션 ID를 입력하세요",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN은 6자리여야 합니다",
"connectionTimedOut": "연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.",
"sessionNotFound": "세션을 찾을 수 없습니다. 자격 증명을 확인하세요.",
"failedToConnect": "연결 실패: ${error}",
"failedToLoadRecent": "최근 세션 로드 실패: ${error}"
"failedToConnect": "연결 실패: ${error}"
},
"remote": {
"disconnectConfirm": "원격 세션 연결을 해제하시겠습니까?",
@@ -806,7 +806,8 @@
"subtitleSync": "자막 동기화",
"hdr": "HDR",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이"
"performanceOverlay": "성능 오버레이",
"audioPassthrough": "오디오 패스스루"
},
"externalPlayer": {
"title": "외부 플레이어",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Weet je zeker dat je wilt afsluiten?",
"dontAskAgain": "Niet meer vragen",
"exit": "Afsluiten",
"viewAll": "Alles weergeven"
"viewAll": "Alles weergeven",
"checkingNetwork": "Netwerk controleren...",
"refreshingServers": "Servers vernieuwen...",
"loadingServers": "Servers laden...",
"connectingToServers": "Verbinden met servers...",
"startingOfflineMode": "Offlinemodus starten..."
},
"screens": {
"licenses": "Licenties",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan",
"showUnwatchedCount": "Aantal ongekeken tonen",
"showUnwatchedCountDescription": "Toon aantal ongekeken afleveringen bij series en seizoenen",
"hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen",
"hideSpoilersDescription": "Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien",
"playerBackend": "Speler backend",
"exoPlayer": "ExoPlayer (Aanbevolen)",
"exoPlayerDescription": "Android-native speler met betere hardware-ondersteuning",
@@ -266,8 +273,9 @@
"goToSeason": "Ga naar seizoen",
"shufflePlay": "Willekeurig afspelen",
"fileInfo": "Bestand info",
"confirmDelete": "Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?",
"deleteMultipleWarning": "Meerdere items kunnen worden verwijderd.",
"deleteFromServer": "Verwijderen van server",
"confirmDelete": "Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.",
"deleteMultipleWarning": "Dit omvat alle afleveringen en hun bestanden.",
"mediaDeletedSuccessfully": "Media-item succesvol verwijderd",
"mediaFailedToDelete": "Verwijderen van media-item mislukt",
"rate": "Beoordelen"
@@ -732,11 +740,8 @@
"minimize": "Minimaliseren"
},
"pairing": {
"recent": "Recent",
"scan": "Scannen",
"manual": "Handmatig",
"recentConnections": "Recente verbindingen",
"quickReconnect": "Snel opnieuw verbinden met eerder gekoppelde apparaten",
"pairWithDesktop": "Koppelen met desktop",
"enterSessionDetails": "Voer de sessiegegevens in die op je desktop-apparaat worden getoond",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.",
"cameraError": "Kan camera niet starten: ${error}",
"scanInstruction": "Richt je camera op de QR-code die op je desktop wordt getoond",
"noRecentConnections": "Geen recente verbindingen",
"connectUsingManual": "Verbind met een apparaat via Handmatige invoer om te beginnen",
"invalidQrCode": "Ongeldig QR-codeformaat",
"removeRecentConnection": "Recente verbinding verwijderen",
"removeConfirm": "\"${name}\" verwijderen uit recente verbindingen?",
"validationHostRequired": "Voer een hostadres in",
"validationHostFormat": "Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)",
"validationSessionIdRequired": "Voer een sessie-ID in",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN moet 6 cijfers zijn",
"connectionTimedOut": "Verbinding verlopen. Controleer de sessie-ID en PIN.",
"sessionNotFound": "Kan de sessie niet vinden. Controleer je gegevens.",
"failedToConnect": "Verbinden mislukt: ${error}",
"failedToLoadRecent": "Kan recente sessies niet laden: ${error}"
"failedToConnect": "Verbinden mislukt: ${error}"
},
"remote": {
"disconnectConfirm": "Wil je de verbinding met de externe sessie verbreken?",
@@ -806,7 +806,8 @@
"subtitleSync": "Ondertitel synchronisatie",
"hdr": "HDR",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay"
"performanceOverlay": "Prestatie-overlay",
"audioPassthrough": "Audio-doorvoer"
},
"externalPlayer": {
"title": "Externe speler",
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonDe implements TranslationsCommonEn {
@override String get dontAskAgain => 'Nicht erneut fragen';
@override String get exit => 'Beenden';
@override String get viewAll => 'Alle anzeigen';
@override String get checkingNetwork => 'Netzwerk wird geprüft...';
@override String get refreshingServers => 'Server werden aktualisiert...';
@override String get loadingServers => 'Server werden geladen...';
@override String get connectingToServers => 'Verbindung zu Servern...';
@override String get startingOfflineMode => 'Offlinemodus wird gestartet...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an';
@override String get showUnwatchedCount => 'Anzahl nicht gesehener Folgen anzeigen';
@override String get showUnwatchedCountDescription => 'Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an';
@override String get hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen';
@override String get hideSpoilersDescription => 'Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden';
@override String get playerBackend => 'Player-Backend';
@override String get exoPlayer => 'ExoPlayer (Empfohlen)';
@override String get exoPlayerDescription => 'Android-nativer Player mit besserer Hardware-Unterstützung';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Zur Staffel';
@override String get shufflePlay => 'Zufallswiedergabe';
@override String get fileInfo => 'Dateiinfo';
@override String get confirmDelete => 'Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?';
@override String get deleteMultipleWarning => 'Mehrere Elemente können gelöscht werden.';
@override String get deleteFromServer => 'Vom Server löschen';
@override String get confirmDelete => 'Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override String get deleteMultipleWarning => 'Dies umfasst alle Episoden und deren Dateien.';
@override String get mediaDeletedSuccessfully => 'Medienelement erfolgreich gelöscht';
@override String get mediaFailedToDelete => 'Löschen des Medienelements fehlgeschlagen';
@override String get rate => 'Bewerten';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsDe implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audioausgabe';
@override String get performanceOverlay => 'Leistungsanzeige';
@override String get audioPassthrough => 'Audio-Durchleitung';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get recent => 'Zuletzt';
@override String get scan => 'Scannen';
@override String get manual => 'Manuell';
@override String get recentConnections => 'Letzte Verbindungen';
@override String get quickReconnect => 'Schnell mit zuvor gekoppelten Geräten verbinden';
@override String get pairWithDesktop => 'Mit Desktop koppeln';
@override String get enterSessionDetails => 'Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.';
@override String cameraError({required Object error}) => 'Kamera konnte nicht gestartet werden: ${error}';
@override String get scanInstruction => 'Richte deine Kamera auf den QR-Code auf deinem Desktop';
@override String get noRecentConnections => 'Keine letzten Verbindungen';
@override String get connectUsingManual => 'Verbinde dich über die manuelle Eingabe, um loszulegen';
@override String get invalidQrCode => 'Ungültiges QR-Code-Format';
@override String get removeRecentConnection => 'Letzte Verbindung entfernen';
@override String removeConfirm({required Object name}) => '"${name}" aus den letzten Verbindungen entfernen?';
@override String get validationHostRequired => 'Bitte Host-Adresse eingeben';
@override String get validationHostFormat => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Bitte Sitzungs-ID eingeben';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.';
@override String get sessionNotFound => 'Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.';
@override String failedToConnect({required Object error}) => 'Verbindung fehlgeschlagen: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Letzte Sitzungen konnten nicht geladen werden: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsDe {
'common.dontAskAgain' => 'Nicht erneut fragen',
'common.exit' => 'Beenden',
'common.viewAll' => 'Alle anzeigen',
'common.checkingNetwork' => 'Netzwerk wird geprüft...',
'common.refreshingServers' => 'Server werden aktualisiert...',
'common.loadingServers' => 'Server werden geladen...',
'common.connectingToServers' => 'Verbindung zu Servern...',
'common.startingOfflineMode' => 'Offlinemodus wird gestartet...',
'screens.licenses' => 'Lizenzen',
'screens.switchProfile' => 'Profil wechseln',
'screens.subtitleStyling' => 'Untertitel-Stil',
@@ -1401,6 +1407,8 @@ extension on TranslationsDe {
'settings.alwaysKeepSidebarOpenDescription' => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an',
'settings.showUnwatchedCount' => 'Anzahl nicht gesehener Folgen anzeigen',
'settings.showUnwatchedCountDescription' => 'Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an',
'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen',
'settings.hideSpoilersDescription' => 'Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden',
'settings.playerBackend' => 'Player-Backend',
'settings.exoPlayer' => 'ExoPlayer (Empfohlen)',
'settings.exoPlayerDescription' => 'Android-nativer Player mit besserer Hardware-Unterstützung',
@@ -1538,8 +1546,9 @@ extension on TranslationsDe {
'mediaMenu.goToSeason' => 'Zur Staffel',
'mediaMenu.shufflePlay' => 'Zufallswiedergabe',
'mediaMenu.fileInfo' => 'Dateiinfo',
'mediaMenu.confirmDelete' => 'Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?',
'mediaMenu.deleteMultipleWarning' => 'Mehrere Elemente können gelöscht werden.',
'mediaMenu.deleteFromServer' => 'Vom Server löschen',
'mediaMenu.confirmDelete' => 'Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.',
'mediaMenu.deleteMultipleWarning' => 'Dies umfasst alle Episoden und deren Dateien.',
'mediaMenu.mediaDeletedSuccessfully' => 'Medienelement erfolgreich gelöscht',
'mediaMenu.mediaFailedToDelete' => 'Löschen des Medienelements fehlgeschlagen',
'mediaMenu.rate' => 'Bewerten',
@@ -1949,11 +1958,8 @@ extension on TranslationsDe {
'companionRemote.session.copyToClipboard' => 'In Zwischenablage kopieren',
'companionRemote.session.newSession' => 'Neue Sitzung',
'companionRemote.session.minimize' => 'Minimieren',
'companionRemote.pairing.recent' => 'Zuletzt',
'companionRemote.pairing.scan' => 'Scannen',
'companionRemote.pairing.manual' => 'Manuell',
'companionRemote.pairing.recentConnections' => 'Letzte Verbindungen',
'companionRemote.pairing.quickReconnect' => 'Schnell mit zuvor gekoppelten Geräten verbinden',
'companionRemote.pairing.pairWithDesktop' => 'Mit Desktop koppeln',
'companionRemote.pairing.enterSessionDetails' => 'Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsDe {
'companionRemote.pairing.cameraPermissionRequired' => 'Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kamera konnte nicht gestartet werden: ${error}',
'companionRemote.pairing.scanInstruction' => 'Richte deine Kamera auf den QR-Code auf deinem Desktop',
'companionRemote.pairing.noRecentConnections' => 'Keine letzten Verbindungen',
'companionRemote.pairing.connectUsingManual' => 'Verbinde dich über die manuelle Eingabe, um loszulegen',
'companionRemote.pairing.invalidQrCode' => 'Ungültiges QR-Code-Format',
'companionRemote.pairing.removeRecentConnection' => 'Letzte Verbindung entfernen',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}" aus den letzten Verbindungen entfernen?',
'companionRemote.pairing.validationHostRequired' => 'Bitte Host-Adresse eingeben',
'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Bitte Sitzungs-ID eingeben',
@@ -1981,7 +1983,6 @@ extension on TranslationsDe {
'companionRemote.pairing.connectionTimedOut' => 'Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.',
'companionRemote.pairing.sessionNotFound' => 'Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Letzte Sitzungen konnten nicht geladen werden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?',
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Versuch ${current} von 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsDe {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audioausgabe',
'videoSettings.performanceOverlay' => 'Leistungsanzeige',
'videoSettings.audioPassthrough' => 'Audio-Durchleitung',
'externalPlayer.title' => 'Externer Player',
'externalPlayer.useExternalPlayer' => 'Externen Player verwenden',
'externalPlayer.useExternalPlayerDescription' => 'Videos in einer externen App statt im integrierten Player öffnen',
+42 -37
View File
@@ -252,6 +252,21 @@ class TranslationsCommonEn {
/// en: 'View All'
String get viewAll => 'View All';
/// en: 'Checking network...'
String get checkingNetwork => 'Checking network...';
/// en: 'Refreshing servers...'
String get refreshingServers => 'Refreshing servers...';
/// en: 'Loading servers...'
String get loadingServers => 'Loading servers...';
/// en: 'Connecting to servers...'
String get connectingToServers => 'Connecting to servers...';
/// en: 'Starting offline mode...'
String get startingOfflineMode => 'Starting offline mode...';
}
// Path: screens
@@ -454,6 +469,12 @@ class TranslationsSettingsEn {
/// en: 'Display unwatched episode count on shows and seasons'
String get showUnwatchedCountDescription => 'Display unwatched episode count on shows and seasons';
/// en: 'Hide Spoilers for Unwatched Episodes'
String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes';
/// en: 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet'
String get hideSpoilersDescription => 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet';
/// en: 'Player Backend'
String get playerBackend => 'Player Backend';
@@ -855,11 +876,14 @@ class TranslationsMediaMenuEn {
/// en: 'File Info'
String get fileInfo => 'File Info';
/// en: 'Are you sure you want to delete this item from your filesystem?'
String get confirmDelete => 'Are you sure you want to delete this item from your filesystem?';
/// en: 'Delete from server'
String get deleteFromServer => 'Delete from server';
/// en: 'Multiple items may be deleted.'
String get deleteMultipleWarning => 'Multiple items may be deleted.';
/// en: 'This will permanently delete this media and its files from your server. This cannot be undone.'
String get confirmDelete => 'This will permanently delete this media and its files from your server. This cannot be undone.';
/// en: 'This includes all episodes and their files.'
String get deleteMultipleWarning => 'This includes all episodes and their files.';
/// en: 'Media item deleted successfully'
String get mediaDeletedSuccessfully => 'Media item deleted successfully';
@@ -2249,6 +2273,9 @@ class TranslationsVideoSettingsEn {
/// en: 'Performance Overlay'
String get performanceOverlay => 'Performance Overlay';
/// en: 'Audio Passthrough'
String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -2691,21 +2718,12 @@ class TranslationsCompanionRemotePairingEn {
// Translations
/// en: 'Recent'
String get recent => 'Recent';
/// en: 'Scan'
String get scan => 'Scan';
/// en: 'Manual'
String get manual => 'Manual';
/// en: 'Recent Connections'
String get recentConnections => 'Recent Connections';
/// en: 'Quickly reconnect to previously paired devices'
String get quickReconnect => 'Quickly reconnect to previously paired devices';
/// en: 'Pair with Desktop'
String get pairWithDesktop => 'Pair with Desktop';
@@ -2745,21 +2763,9 @@ class TranslationsCompanionRemotePairingEn {
/// en: 'Point your camera at the QR code shown on your desktop'
String get scanInstruction => 'Point your camera at the QR code shown on your desktop';
/// en: 'No recent connections'
String get noRecentConnections => 'No recent connections';
/// en: 'Connect to a device using Manual entry to get started'
String get connectUsingManual => 'Connect to a device using Manual entry to get started';
/// en: 'Invalid QR code format'
String get invalidQrCode => 'Invalid QR code format';
/// en: 'Remove Recent Connection'
String get removeRecentConnection => 'Remove Recent Connection';
/// en: 'Remove "${name}" from recent connections?'
String removeConfirm({required Object name}) => 'Remove "${name}" from recent connections?';
/// en: 'Please enter host address'
String get validationHostRequired => 'Please enter host address';
@@ -2787,8 +2793,6 @@ class TranslationsCompanionRemotePairingEn {
/// en: 'Failed to connect: ${error}'
String failedToConnect({required Object error}) => 'Failed to connect: ${error}';
/// en: 'Failed to load recent sessions: ${error}'
String failedToLoadRecent({required Object error}) => 'Failed to load recent sessions: ${error}';
}
// Path: companionRemote.remote
@@ -2944,6 +2948,11 @@ extension on Translations {
'common.dontAskAgain' => 'Don\'t ask again',
'common.exit' => 'Exit',
'common.viewAll' => 'View All',
'common.checkingNetwork' => 'Checking network...',
'common.refreshingServers' => 'Refreshing servers...',
'common.loadingServers' => 'Loading servers...',
'common.connectingToServers' => 'Connecting to servers...',
'common.startingOfflineMode' => 'Starting offline mode...',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Switch Profile',
'screens.subtitleStyling' => 'Subtitle Styling',
@@ -3002,6 +3011,8 @@ extension on Translations {
'settings.alwaysKeepSidebarOpenDescription' => 'Sidebar stays expanded and content area adjusts to fit',
'settings.showUnwatchedCount' => 'Show Unwatched Count',
'settings.showUnwatchedCountDescription' => 'Display unwatched episode count on shows and seasons',
'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes',
'settings.hideSpoilersDescription' => 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet',
'settings.playerBackend' => 'Player Backend',
'settings.exoPlayer' => 'ExoPlayer (Recommended)',
'settings.exoPlayerDescription' => 'Android native player with better hardware support',
@@ -3139,8 +3150,9 @@ extension on Translations {
'mediaMenu.goToSeason' => 'Go to season',
'mediaMenu.shufflePlay' => 'Shuffle Play',
'mediaMenu.fileInfo' => 'File Info',
'mediaMenu.confirmDelete' => 'Are you sure you want to delete this item from your filesystem?',
'mediaMenu.deleteMultipleWarning' => 'Multiple items may be deleted.',
'mediaMenu.deleteFromServer' => 'Delete from server',
'mediaMenu.confirmDelete' => 'This will permanently delete this media and its files from your server. This cannot be undone.',
'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully',
'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item',
'mediaMenu.rate' => 'Rate',
@@ -3550,11 +3562,8 @@ extension on Translations {
'companionRemote.session.copyToClipboard' => 'Copy to clipboard',
'companionRemote.session.newSession' => 'New Session',
'companionRemote.session.minimize' => 'Minimize',
'companionRemote.pairing.recent' => 'Recent',
'companionRemote.pairing.scan' => 'Scan',
'companionRemote.pairing.manual' => 'Manual',
'companionRemote.pairing.recentConnections' => 'Recent Connections',
'companionRemote.pairing.quickReconnect' => 'Quickly reconnect to previously paired devices',
'companionRemote.pairing.pairWithDesktop' => 'Pair with Desktop',
'companionRemote.pairing.enterSessionDetails' => 'Enter the session details shown on your desktop device',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -3568,11 +3577,7 @@ extension on Translations {
'companionRemote.pairing.cameraPermissionRequired' => 'Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Could not start camera: ${error}',
'companionRemote.pairing.scanInstruction' => 'Point your camera at the QR code shown on your desktop',
'companionRemote.pairing.noRecentConnections' => 'No recent connections',
'companionRemote.pairing.connectUsingManual' => 'Connect to a device using Manual entry to get started',
'companionRemote.pairing.invalidQrCode' => 'Invalid QR code format',
'companionRemote.pairing.removeRecentConnection' => 'Remove Recent Connection',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Remove "${name}" from recent connections?',
'companionRemote.pairing.validationHostRequired' => 'Please enter host address',
'companionRemote.pairing.validationHostFormat' => 'Format must be IP:port (e.g., 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Please enter a session ID',
@@ -3582,7 +3587,6 @@ extension on Translations {
'companionRemote.pairing.connectionTimedOut' => 'Connection timed out. Please check the session ID and PIN.',
'companionRemote.pairing.sessionNotFound' => 'Could not find the session. Please check your credentials.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Failed to connect: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Failed to load recent sessions: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Do you want to disconnect from the remote session?',
'companionRemote.remote.reconnecting' => 'Reconnecting...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Attempt ${current} of 5',
@@ -3620,6 +3624,7 @@ extension on Translations {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'External Player',
'externalPlayer.useExternalPlayer' => 'Use External Player',
'externalPlayer.useExternalPlayerDescription' => 'Open videos in an external app instead of the built-in player',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonEs implements TranslationsCommonEn {
@override String get dontAskAgain => 'No volver a preguntar';
@override String get exit => 'Salir';
@override String get viewAll => 'Ver todo';
@override String get checkingNetwork => 'Comprobando red...';
@override String get refreshingServers => 'Actualizando servidores...';
@override String get loadingServers => 'Cargando servidores...';
@override String get connectingToServers => 'Conectando a servidores...';
@override String get startingOfflineMode => 'Iniciando modo sin conexión...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse';
@override String get showUnwatchedCount => 'Mostrar conteo de no vistos';
@override String get showUnwatchedCountDescription => 'Mostrar el conteo de episodios no vistos en series y temporadas';
@override String get hideSpoilers => 'Ocultar spoilers de episodios no vistos';
@override String get hideSpoilersDescription => 'Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto';
@override String get playerBackend => 'Reproductor';
@override String get exoPlayer => 'ExoPlayer (Recomendado)';
@override String get exoPlayerDescription => 'Reproductor nativo de Android con mejor soporte de hardware';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Ir a la temporada';
@override String get shufflePlay => 'Reproducción Aleatoria';
@override String get fileInfo => 'Información del Archivo';
@override String get confirmDelete => '¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?';
@override String get deleteMultipleWarning => 'Es posible que se eliminen varios elementos.';
@override String get deleteFromServer => 'Eliminar del servidor';
@override String get confirmDelete => 'Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.';
@override String get deleteMultipleWarning => 'Esto incluye todos los episodios y sus archivos.';
@override String get mediaDeletedSuccessfully => 'Elemento multimedia eliminado con éxito';
@override String get mediaFailedToDelete => 'Error al eliminar el elemento multimedia';
@override String get rate => 'Calificar';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsEs implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Salida de audio';
@override String get performanceOverlay => 'Indicador de rendimiento';
@override String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get recent => 'Recientes';
@override String get scan => 'Escanear';
@override String get manual => 'Manual';
@override String get recentConnections => 'Conexiones recientes';
@override String get quickReconnect => 'Reconectar rápidamente con dispositivos emparejados anteriormente';
@override String get pairWithDesktop => 'Emparejar con escritorio';
@override String get enterSessionDetails => 'Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.';
@override String cameraError({required Object error}) => 'No se pudo iniciar la cámara: ${error}';
@override String get scanInstruction => 'Apunta tu cámara al código QR que aparece en tu escritorio';
@override String get noRecentConnections => 'No hay conexiones recientes';
@override String get connectUsingManual => 'Conéctate a un dispositivo usando la entrada manual para empezar';
@override String get invalidQrCode => 'Formato de código QR no válido';
@override String get removeRecentConnection => 'Eliminar conexión reciente';
@override String removeConfirm({required Object name}) => '¿Eliminar "${name}" de las conexiones recientes?';
@override String get validationHostRequired => 'Por favor, introduce la dirección del host';
@override String get validationHostFormat => 'El formato debe ser IP:puerto (ej., 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Por favor, introduce un ID de sesión';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.';
@override String get sessionNotFound => 'No se encontró la sesión. Verifica tus credenciales.';
@override String failedToConnect({required Object error}) => 'Error al conectar: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Error al cargar sesiones recientes: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsEs {
'common.dontAskAgain' => 'No volver a preguntar',
'common.exit' => 'Salir',
'common.viewAll' => 'Ver todo',
'common.checkingNetwork' => 'Comprobando red...',
'common.refreshingServers' => 'Actualizando servidores...',
'common.loadingServers' => 'Cargando servidores...',
'common.connectingToServers' => 'Conectando a servidores...',
'common.startingOfflineMode' => 'Iniciando modo sin conexión...',
'screens.licenses' => 'Licencias',
'screens.switchProfile' => 'Cambiar Perfil',
'screens.subtitleStyling' => 'Estilo de Subtítulos',
@@ -1401,6 +1407,8 @@ extension on TranslationsEs {
'settings.alwaysKeepSidebarOpenDescription' => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse',
'settings.showUnwatchedCount' => 'Mostrar conteo de no vistos',
'settings.showUnwatchedCountDescription' => 'Mostrar el conteo de episodios no vistos en series y temporadas',
'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos',
'settings.hideSpoilersDescription' => 'Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto',
'settings.playerBackend' => 'Reproductor',
'settings.exoPlayer' => 'ExoPlayer (Recomendado)',
'settings.exoPlayerDescription' => 'Reproductor nativo de Android con mejor soporte de hardware',
@@ -1538,8 +1546,9 @@ extension on TranslationsEs {
'mediaMenu.goToSeason' => 'Ir a la temporada',
'mediaMenu.shufflePlay' => 'Reproducción Aleatoria',
'mediaMenu.fileInfo' => 'Información del Archivo',
'mediaMenu.confirmDelete' => '¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?',
'mediaMenu.deleteMultipleWarning' => 'Es posible que se eliminen varios elementos.',
'mediaMenu.deleteFromServer' => 'Eliminar del servidor',
'mediaMenu.confirmDelete' => 'Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.',
'mediaMenu.deleteMultipleWarning' => 'Esto incluye todos los episodios y sus archivos.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimedia eliminado con éxito',
'mediaMenu.mediaFailedToDelete' => 'Error al eliminar el elemento multimedia',
'mediaMenu.rate' => 'Calificar',
@@ -1949,11 +1958,8 @@ extension on TranslationsEs {
'companionRemote.session.copyToClipboard' => 'Copiar al portapapeles',
'companionRemote.session.newSession' => 'Nueva sesión',
'companionRemote.session.minimize' => 'Minimizar',
'companionRemote.pairing.recent' => 'Recientes',
'companionRemote.pairing.scan' => 'Escanear',
'companionRemote.pairing.manual' => 'Manual',
'companionRemote.pairing.recentConnections' => 'Conexiones recientes',
'companionRemote.pairing.quickReconnect' => 'Reconectar rápidamente con dispositivos emparejados anteriormente',
'companionRemote.pairing.pairWithDesktop' => 'Emparejar con escritorio',
'companionRemote.pairing.enterSessionDetails' => 'Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsEs {
'companionRemote.pairing.cameraPermissionRequired' => 'Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'No se pudo iniciar la cámara: ${error}',
'companionRemote.pairing.scanInstruction' => 'Apunta tu cámara al código QR que aparece en tu escritorio',
'companionRemote.pairing.noRecentConnections' => 'No hay conexiones recientes',
'companionRemote.pairing.connectUsingManual' => 'Conéctate a un dispositivo usando la entrada manual para empezar',
'companionRemote.pairing.invalidQrCode' => 'Formato de código QR no válido',
'companionRemote.pairing.removeRecentConnection' => 'Eliminar conexión reciente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '¿Eliminar "${name}" de las conexiones recientes?',
'companionRemote.pairing.validationHostRequired' => 'Por favor, introduce la dirección del host',
'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (ej., 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Por favor, introduce un ID de sesión',
@@ -1981,7 +1983,6 @@ extension on TranslationsEs {
'companionRemote.pairing.connectionTimedOut' => 'Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.',
'companionRemote.pairing.sessionNotFound' => 'No se encontró la sesión. Verifica tus credenciales.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Error al cargar sesiones recientes: ${error}',
'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Intento ${current} de 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsEs {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Salida de audio',
'videoSettings.performanceOverlay' => 'Indicador de rendimiento',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'Reproductor externo',
'externalPlayer.useExternalPlayer' => 'Usar reproductor externo',
'externalPlayer.useExternalPlayerDescription' => 'Abrir vídeos en una app externa en lugar del reproductor integrado',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonFr implements TranslationsCommonEn {
@override String get dontAskAgain => 'Ne plus demander';
@override String get exit => 'Quitter';
@override String get viewAll => 'Tout afficher';
@override String get checkingNetwork => 'Vérification du réseau...';
@override String get refreshingServers => 'Actualisation des serveurs...';
@override String get loadingServers => 'Chargement des serveurs...';
@override String get connectingToServers => 'Connexion aux serveurs...';
@override String get startingOfflineMode => 'Démarrage en mode hors-ligne...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barre latérale reste étendue et la zone de contenu s\'adapte';
@override String get showUnwatchedCount => 'Afficher le nombre non visionné';
@override String get showUnwatchedCountDescription => 'Afficher le nombre d\'épisodes non visionnés pour les séries et saisons';
@override String get hideSpoilers => 'Masquer les spoilers des épisodes non vus';
@override String get hideSpoilersDescription => 'Flouter les miniatures et masquer les descriptions des épisodes que vous n\'avez pas encore regardés';
@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';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Aller à la saison';
@override String get shufflePlay => 'Lecture aléatoire';
@override String get fileInfo => 'Informations sur le fichier';
@override String get confirmDelete => 'Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?';
@override String get deleteMultipleWarning => 'Plusieurs éléments peuvent être supprimés.';
@override String get deleteFromServer => 'Supprimer du serveur';
@override String get confirmDelete => 'Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.';
@override String get deleteMultipleWarning => 'Cela inclut tous les épisodes et leurs fichiers.';
@override String get mediaDeletedSuccessfully => 'Élément média supprimé avec succès';
@override String get mediaFailedToDelete => 'Échec de la suppression de l\'élément média';
@override String get rate => 'Noter';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsFr implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Sortie audio';
@override String get performanceOverlay => 'Superposition de performance';
@override String get audioPassthrough => 'Audio Pass-Through';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
final TranslationsFr _root; // ignore: unused_field
// Translations
@override String get recent => 'Récents';
@override String get scan => 'Scanner';
@override String get manual => 'Manuel';
@override String get recentConnections => 'Connexions récentes';
@override String get quickReconnect => 'Reconnexion rapide aux appareils précédemment jumelés';
@override String get pairWithDesktop => 'Jumeler avec un bureau';
@override String get enterSessionDetails => 'Saisissez les détails de la session affichés sur votre appareil de bureau';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'L\'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l\'accès à la caméra dans les paramètres de votre appareil.';
@override String cameraError({required Object error}) => 'Impossible de démarrer la caméra : ${error}';
@override String get scanInstruction => 'Pointez votre caméra vers le QR code affiché sur votre bureau';
@override String get noRecentConnections => 'Aucune connexion récente';
@override String get connectUsingManual => 'Connectez-vous à un appareil via la saisie manuelle pour commencer';
@override String get invalidQrCode => 'Format de QR code invalide';
@override String get removeRecentConnection => 'Supprimer la connexion récente';
@override String removeConfirm({required Object name}) => 'Supprimer "${name}" des connexions récentes ?';
@override String get validationHostRequired => 'Veuillez saisir l\'adresse de l\'hôte';
@override String get validationHostFormat => 'Le format doit être IP:port (ex : 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Veuillez saisir un ID de session';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Délai de connexion expiré. Veuillez vérifier l\'ID de session et le PIN.';
@override String get sessionNotFound => 'Session introuvable. Veuillez vérifier vos identifiants.';
@override String failedToConnect({required Object error}) => 'Échec de la connexion : ${error}';
@override String failedToLoadRecent({required Object error}) => 'Échec du chargement des sessions récentes : ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsFr {
'common.dontAskAgain' => 'Ne plus demander',
'common.exit' => 'Quitter',
'common.viewAll' => 'Tout afficher',
'common.checkingNetwork' => 'Vérification du réseau...',
'common.refreshingServers' => 'Actualisation des serveurs...',
'common.loadingServers' => 'Chargement des serveurs...',
'common.connectingToServers' => 'Connexion aux serveurs...',
'common.startingOfflineMode' => 'Démarrage en mode hors-ligne...',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Changer de profil',
'screens.subtitleStyling' => 'Configuration des sous-titres',
@@ -1401,6 +1407,8 @@ extension on TranslationsFr {
'settings.alwaysKeepSidebarOpenDescription' => 'La barre latérale reste étendue et la zone de contenu s\'adapte',
'settings.showUnwatchedCount' => 'Afficher le nombre non visionné',
'settings.showUnwatchedCountDescription' => 'Afficher le nombre d\'épisodes non visionnés pour les séries et saisons',
'settings.hideSpoilers' => 'Masquer les spoilers des épisodes non vus',
'settings.hideSpoilersDescription' => 'Flouter les miniatures et masquer les descriptions des épisodes que vous n\'avez pas encore regardés',
'settings.playerBackend' => 'Moteur de lecture',
'settings.exoPlayer' => 'ExoPlayer (Recommandé)',
'settings.exoPlayerDescription' => 'Lecteur natif Android avec meilleur support matériel',
@@ -1538,8 +1546,9 @@ extension on TranslationsFr {
'mediaMenu.goToSeason' => 'Aller à la saison',
'mediaMenu.shufflePlay' => 'Lecture aléatoire',
'mediaMenu.fileInfo' => 'Informations sur le fichier',
'mediaMenu.confirmDelete' => 'Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?',
'mediaMenu.deleteMultipleWarning' => 'Plusieurs éléments peuvent être supprimés.',
'mediaMenu.deleteFromServer' => 'Supprimer du serveur',
'mediaMenu.confirmDelete' => 'Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.',
'mediaMenu.deleteMultipleWarning' => 'Cela inclut tous les épisodes et leurs fichiers.',
'mediaMenu.mediaDeletedSuccessfully' => 'Élément média supprimé avec succès',
'mediaMenu.mediaFailedToDelete' => 'Échec de la suppression de l\'élément média',
'mediaMenu.rate' => 'Noter',
@@ -1949,11 +1958,8 @@ extension on TranslationsFr {
'companionRemote.session.copyToClipboard' => 'Copier dans le presse-papiers',
'companionRemote.session.newSession' => 'Nouvelle session',
'companionRemote.session.minimize' => 'Réduire',
'companionRemote.pairing.recent' => 'Récents',
'companionRemote.pairing.scan' => 'Scanner',
'companionRemote.pairing.manual' => 'Manuel',
'companionRemote.pairing.recentConnections' => 'Connexions récentes',
'companionRemote.pairing.quickReconnect' => 'Reconnexion rapide aux appareils précédemment jumelés',
'companionRemote.pairing.pairWithDesktop' => 'Jumeler avec un bureau',
'companionRemote.pairing.enterSessionDetails' => 'Saisissez les détails de la session affichés sur votre appareil de bureau',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsFr {
'companionRemote.pairing.cameraPermissionRequired' => 'L\'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l\'accès à la caméra dans les paramètres de votre appareil.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Impossible de démarrer la caméra : ${error}',
'companionRemote.pairing.scanInstruction' => 'Pointez votre caméra vers le QR code affiché sur votre bureau',
'companionRemote.pairing.noRecentConnections' => 'Aucune connexion récente',
'companionRemote.pairing.connectUsingManual' => 'Connectez-vous à un appareil via la saisie manuelle pour commencer',
'companionRemote.pairing.invalidQrCode' => 'Format de QR code invalide',
'companionRemote.pairing.removeRecentConnection' => 'Supprimer la connexion récente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Supprimer "${name}" des connexions récentes ?',
'companionRemote.pairing.validationHostRequired' => 'Veuillez saisir l\'adresse de l\'hôte',
'companionRemote.pairing.validationHostFormat' => 'Le format doit être IP:port (ex : 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Veuillez saisir un ID de session',
@@ -1981,7 +1983,6 @@ extension on TranslationsFr {
'companionRemote.pairing.connectionTimedOut' => 'Délai de connexion expiré. Veuillez vérifier l\'ID de session et le PIN.',
'companionRemote.pairing.sessionNotFound' => 'Session introuvable. Veuillez vérifier vos identifiants.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Échec du chargement des sessions récentes : ${error}',
'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?',
'companionRemote.remote.reconnecting' => 'Reconnexion...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentative ${current} sur 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsFr {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Sortie audio',
'videoSettings.performanceOverlay' => 'Superposition de performance',
'videoSettings.audioPassthrough' => 'Audio Pass-Through',
'externalPlayer.title' => 'Lecteur externe',
'externalPlayer.useExternalPlayer' => 'Utiliser un lecteur externe',
'externalPlayer.useExternalPlayerDescription' => 'Ouvrir les vidéos dans une application externe au lieu du lecteur intégré',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonIt implements TranslationsCommonEn {
@override String get dontAskAgain => 'Non chiedere più';
@override String get exit => 'Esci';
@override String get viewAll => 'Mostra tutto';
@override String get checkingNetwork => 'Verifica rete...';
@override String get refreshingServers => 'Aggiornamento server...';
@override String get loadingServers => 'Caricamento server...';
@override String get connectingToServers => 'Connessione ai server...';
@override String get startingOfflineMode => 'Avvio modalità offline...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barra laterale rimane espansa e l\'area del contenuto si adatta';
@override String get showUnwatchedCount => 'Mostra conteggio non visti';
@override String get showUnwatchedCountDescription => 'Mostra il numero di episodi non visti per serie e stagioni';
@override String get hideSpoilers => 'Nascondi spoiler per episodi non visti';
@override String get hideSpoilersDescription => 'Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato';
@override String get playerBackend => 'Motore di riproduzione';
@override String get exoPlayer => 'ExoPlayer (Consigliato)';
@override String get exoPlayerDescription => 'Lettore nativo Android con migliore supporto hardware';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Vai alla stagione';
@override String get shufflePlay => 'Riproduzione casuale';
@override String get fileInfo => 'Info sul file';
@override String get confirmDelete => 'Sei sicuro di voler eliminare questo elemento dal tuo filesystem?';
@override String get deleteMultipleWarning => 'Potrebbero essere eliminati più elementi.';
@override String get deleteFromServer => 'Elimina dal server';
@override String get confirmDelete => 'Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.';
@override String get deleteMultipleWarning => 'Questo include tutti gli episodi e i loro file.';
@override String get mediaDeletedSuccessfully => 'Elemento multimediale eliminato con successo';
@override String get mediaFailedToDelete => 'Impossibile eliminare l\'elemento multimediale';
@override String get rate => 'Valuta';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsIt implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Uscita audio';
@override String get performanceOverlay => 'Overlay prestazioni';
@override String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
final TranslationsIt _root; // ignore: unused_field
// Translations
@override String get recent => 'Recenti';
@override String get scan => 'Scansiona';
@override String get manual => 'Manuale';
@override String get recentConnections => 'Connessioni recenti';
@override String get quickReconnect => 'Riconnettiti rapidamente ai dispositivi associati in precedenza';
@override String get pairWithDesktop => 'Associa con desktop';
@override String get enterSessionDetails => 'Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'L\'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l\'accesso alla fotocamera nelle impostazioni del dispositivo.';
@override String cameraError({required Object error}) => 'Impossibile avviare la fotocamera: ${error}';
@override String get scanInstruction => 'Punta la fotocamera verso il QR code mostrato sul tuo desktop';
@override String get noRecentConnections => 'Nessuna connessione recente';
@override String get connectUsingManual => 'Connettiti a un dispositivo tramite inserimento manuale per iniziare';
@override String get invalidQrCode => 'Formato QR code non valido';
@override String get removeRecentConnection => 'Rimuovi connessione recente';
@override String removeConfirm({required Object name}) => 'Rimuovere "${name}" dalle connessioni recenti?';
@override String get validationHostRequired => 'Inserisci l\'indirizzo host';
@override String get validationHostFormat => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Inserisci un ID sessione';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Connessione scaduta. Verifica l\'ID sessione e il PIN.';
@override String get sessionNotFound => 'Sessione non trovata. Verifica le tue credenziali.';
@override String failedToConnect({required Object error}) => 'Connessione fallita: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Impossibile caricare le sessioni recenti: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsIt {
'common.dontAskAgain' => 'Non chiedere più',
'common.exit' => 'Esci',
'common.viewAll' => 'Mostra tutto',
'common.checkingNetwork' => 'Verifica rete...',
'common.refreshingServers' => 'Aggiornamento server...',
'common.loadingServers' => 'Caricamento server...',
'common.connectingToServers' => 'Connessione ai server...',
'common.startingOfflineMode' => 'Avvio modalità offline...',
'screens.licenses' => 'Licenze',
'screens.switchProfile' => 'Cambia profilo',
'screens.subtitleStyling' => 'Stile sottotitoli',
@@ -1401,6 +1407,8 @@ extension on TranslationsIt {
'settings.alwaysKeepSidebarOpenDescription' => 'La barra laterale rimane espansa e l\'area del contenuto si adatta',
'settings.showUnwatchedCount' => 'Mostra conteggio non visti',
'settings.showUnwatchedCountDescription' => 'Mostra il numero di episodi non visti per serie e stagioni',
'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti',
'settings.hideSpoilersDescription' => 'Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato',
'settings.playerBackend' => 'Motore di riproduzione',
'settings.exoPlayer' => 'ExoPlayer (Consigliato)',
'settings.exoPlayerDescription' => 'Lettore nativo Android con migliore supporto hardware',
@@ -1538,8 +1546,9 @@ extension on TranslationsIt {
'mediaMenu.goToSeason' => 'Vai alla stagione',
'mediaMenu.shufflePlay' => 'Riproduzione casuale',
'mediaMenu.fileInfo' => 'Info sul file',
'mediaMenu.confirmDelete' => 'Sei sicuro di voler eliminare questo elemento dal tuo filesystem?',
'mediaMenu.deleteMultipleWarning' => 'Potrebbero essere eliminati più elementi.',
'mediaMenu.deleteFromServer' => 'Elimina dal server',
'mediaMenu.confirmDelete' => 'Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.',
'mediaMenu.deleteMultipleWarning' => 'Questo include tutti gli episodi e i loro file.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimediale eliminato con successo',
'mediaMenu.mediaFailedToDelete' => 'Impossibile eliminare l\'elemento multimediale',
'mediaMenu.rate' => 'Valuta',
@@ -1949,11 +1958,8 @@ extension on TranslationsIt {
'companionRemote.session.copyToClipboard' => 'Copia negli appunti',
'companionRemote.session.newSession' => 'Nuova sessione',
'companionRemote.session.minimize' => 'Riduci',
'companionRemote.pairing.recent' => 'Recenti',
'companionRemote.pairing.scan' => 'Scansiona',
'companionRemote.pairing.manual' => 'Manuale',
'companionRemote.pairing.recentConnections' => 'Connessioni recenti',
'companionRemote.pairing.quickReconnect' => 'Riconnettiti rapidamente ai dispositivi associati in precedenza',
'companionRemote.pairing.pairWithDesktop' => 'Associa con desktop',
'companionRemote.pairing.enterSessionDetails' => 'Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsIt {
'companionRemote.pairing.cameraPermissionRequired' => 'L\'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l\'accesso alla fotocamera nelle impostazioni del dispositivo.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Impossibile avviare la fotocamera: ${error}',
'companionRemote.pairing.scanInstruction' => 'Punta la fotocamera verso il QR code mostrato sul tuo desktop',
'companionRemote.pairing.noRecentConnections' => 'Nessuna connessione recente',
'companionRemote.pairing.connectUsingManual' => 'Connettiti a un dispositivo tramite inserimento manuale per iniziare',
'companionRemote.pairing.invalidQrCode' => 'Formato QR code non valido',
'companionRemote.pairing.removeRecentConnection' => 'Rimuovi connessione recente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Rimuovere "${name}" dalle connessioni recenti?',
'companionRemote.pairing.validationHostRequired' => 'Inserisci l\'indirizzo host',
'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Inserisci un ID sessione',
@@ -1981,7 +1983,6 @@ extension on TranslationsIt {
'companionRemote.pairing.connectionTimedOut' => 'Connessione scaduta. Verifica l\'ID sessione e il PIN.',
'companionRemote.pairing.sessionNotFound' => 'Sessione non trovata. Verifica le tue credenziali.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione fallita: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Impossibile caricare le sessioni recenti: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?',
'companionRemote.remote.reconnecting' => 'Riconnessione...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativo ${current} di 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsIt {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Uscita audio',
'videoSettings.performanceOverlay' => 'Overlay prestazioni',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'Lettore esterno',
'externalPlayer.useExternalPlayer' => 'Usa lettore esterno',
'externalPlayer.useExternalPlayerDescription' => 'Apri i video in un\'app esterna invece del lettore integrato',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonKo implements TranslationsCommonEn {
@override String get dontAskAgain => '다시 묻지 않기';
@override String get exit => '종료';
@override String get viewAll => '모두 보기';
@override String get checkingNetwork => '네트워크 확인 중...';
@override String get refreshingServers => '서버 새로고침 중...';
@override String get loadingServers => '서버 로딩 중...';
@override String get connectingToServers => '서버 연결 중...';
@override String get startingOfflineMode => '오프라인 모드 시작 중...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다';
@override String get showUnwatchedCount => '미시청 수 표시';
@override String get showUnwatchedCountDescription => '시리즈 및 시즌에 미시청 에피소드 수 표시';
@override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기';
@override String get hideSpoilersDescription => '아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다';
@override String get playerBackend => '플레이어 백엔드';
@override String get exoPlayer => 'ExoPlayer (권장)';
@override String get exoPlayerDescription => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn {
@override String get goToSeason => '시즌으로 이동';
@override String get shufflePlay => '무작위 재생';
@override String get fileInfo => '파일 정보';
@override String get confirmDelete => '파일 시스템에서 이 항목을 삭제하시겠습니까?';
@override String get deleteMultipleWarning => '여러 항목이 삭제될습니다.';
@override String get deleteFromServer => '서버에서 삭제';
@override String get confirmDelete => '이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.';
@override String get deleteMultipleWarning => '모든 에피소드와 파일이 포함됩니다.';
@override String get mediaDeletedSuccessfully => '미디어 항목이 성공적으로 삭제되었습니다';
@override String get mediaFailedToDelete => '미디어 항목 삭제 실패';
@override String get rate => '평가';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => '오디오 출력';
@override String get performanceOverlay => '성능 오버레이';
@override String get audioPassthrough => '오디오 패스스루';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
final TranslationsKo _root; // ignore: unused_field
// Translations
@override String get recent => '최근';
@override String get scan => '스캔';
@override String get manual => '수동';
@override String get recentConnections => '최근 연결';
@override String get quickReconnect => '이전에 페어링한 기기에 빠르게 재연결';
@override String get pairWithDesktop => '데스크톱과 페어링';
@override String get enterSessionDetails => '데스크톱 기기에 표시된 세션 정보를 입력하세요';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.';
@override String cameraError({required Object error}) => '카메라를 시작할 수 없습니다: ${error}';
@override String get scanInstruction => '데스크톱에 표시된 QR 코드에 카메라를 향하세요';
@override String get noRecentConnections => '최근 연결 없음';
@override String get connectUsingManual => '수동 입력으로 기기에 연결하여 시작하세요';
@override String get invalidQrCode => '유효하지 않은 QR 코드 형식';
@override String get removeRecentConnection => '최근 연결 삭제';
@override String removeConfirm({required Object name}) => '"${name}"을(를) 최근 연결에서 삭제하시겠습니까?';
@override String get validationHostRequired => '호스트 주소를 입력하세요';
@override String get validationHostFormat => 'IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)';
@override String get validationSessionIdRequired => '세션 ID를 입력하세요';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
@override String get connectionTimedOut => '연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.';
@override String get sessionNotFound => '세션을 찾을 수 없습니다. 자격 증명을 확인하세요.';
@override String failedToConnect({required Object error}) => '연결 실패: ${error}';
@override String failedToLoadRecent({required Object error}) => '최근 세션 로드 실패: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsKo {
'common.dontAskAgain' => '다시 묻지 않기',
'common.exit' => '종료',
'common.viewAll' => '모두 보기',
'common.checkingNetwork' => '네트워크 확인 중...',
'common.refreshingServers' => '서버 새로고침 중...',
'common.loadingServers' => '서버 로딩 중...',
'common.connectingToServers' => '서버 연결 중...',
'common.startingOfflineMode' => '오프라인 모드 시작 중...',
'screens.licenses' => '라이선스',
'screens.switchProfile' => '프로필 전환',
'screens.subtitleStyling' => '자막 스타일 설정',
@@ -1401,6 +1407,8 @@ extension on TranslationsKo {
'settings.alwaysKeepSidebarOpenDescription' => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다',
'settings.showUnwatchedCount' => '미시청 수 표시',
'settings.showUnwatchedCountDescription' => '시리즈 및 시즌에 미시청 에피소드 수 표시',
'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기',
'settings.hideSpoilersDescription' => '아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다',
'settings.playerBackend' => '플레이어 백엔드',
'settings.exoPlayer' => 'ExoPlayer (권장)',
'settings.exoPlayerDescription' => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어',
@@ -1538,8 +1546,9 @@ extension on TranslationsKo {
'mediaMenu.goToSeason' => '시즌으로 이동',
'mediaMenu.shufflePlay' => '무작위 재생',
'mediaMenu.fileInfo' => '파일 정보',
'mediaMenu.confirmDelete' => '파일 시스템에서 이 항목을 삭제하시겠습니까?',
'mediaMenu.deleteMultipleWarning' => '여러 항목이 삭제될습니다.',
'mediaMenu.deleteFromServer' => '서버에서 삭제',
'mediaMenu.confirmDelete' => '이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.',
'mediaMenu.deleteMultipleWarning' => '모든 에피소드와 파일이 포함됩니다.',
'mediaMenu.mediaDeletedSuccessfully' => '미디어 항목이 성공적으로 삭제되었습니다',
'mediaMenu.mediaFailedToDelete' => '미디어 항목 삭제 실패',
'mediaMenu.rate' => '평가',
@@ -1949,11 +1958,8 @@ extension on TranslationsKo {
'companionRemote.session.copyToClipboard' => '클립보드에 복사',
'companionRemote.session.newSession' => '새 세션',
'companionRemote.session.minimize' => '최소화',
'companionRemote.pairing.recent' => '최근',
'companionRemote.pairing.scan' => '스캔',
'companionRemote.pairing.manual' => '수동',
'companionRemote.pairing.recentConnections' => '최근 연결',
'companionRemote.pairing.quickReconnect' => '이전에 페어링한 기기에 빠르게 재연결',
'companionRemote.pairing.pairWithDesktop' => '데스크톱과 페어링',
'companionRemote.pairing.enterSessionDetails' => '데스크톱 기기에 표시된 세션 정보를 입력하세요',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsKo {
'companionRemote.pairing.cameraPermissionRequired' => 'QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.',
'companionRemote.pairing.cameraError' => ({required Object error}) => '카메라를 시작할 수 없습니다: ${error}',
'companionRemote.pairing.scanInstruction' => '데스크톱에 표시된 QR 코드에 카메라를 향하세요',
'companionRemote.pairing.noRecentConnections' => '최근 연결 없음',
'companionRemote.pairing.connectUsingManual' => '수동 입력으로 기기에 연결하여 시작하세요',
'companionRemote.pairing.invalidQrCode' => '유효하지 않은 QR 코드 형식',
'companionRemote.pairing.removeRecentConnection' => '최근 연결 삭제',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}"을(를) 최근 연결에서 삭제하시겠습니까?',
'companionRemote.pairing.validationHostRequired' => '호스트 주소를 입력하세요',
'companionRemote.pairing.validationHostFormat' => 'IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => '세션 ID를 입력하세요',
@@ -1981,7 +1983,6 @@ extension on TranslationsKo {
'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.',
'companionRemote.pairing.sessionNotFound' => '세션을 찾을 수 없습니다. 자격 증명을 확인하세요.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => '최근 세션 로드 실패: ${error}',
'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?',
'companionRemote.remote.reconnecting' => '재연결 중...',
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current}/5 시도 중',
@@ -2019,6 +2020,7 @@ extension on TranslationsKo {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '오디오 출력',
'videoSettings.performanceOverlay' => '성능 오버레이',
'videoSettings.audioPassthrough' => '오디오 패스스루',
'externalPlayer.title' => '외부 플레이어',
'externalPlayer.useExternalPlayer' => '외부 플레이어 사용',
'externalPlayer.useExternalPlayerDescription' => '내장 플레이어 대신 외부 앱에서 동영상 열기',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonNl implements TranslationsCommonEn {
@override String get dontAskAgain => 'Niet meer vragen';
@override String get exit => 'Afsluiten';
@override String get viewAll => 'Alles weergeven';
@override String get checkingNetwork => 'Netwerk controleren...';
@override String get refreshingServers => 'Servers vernieuwen...';
@override String get loadingServers => 'Servers laden...';
@override String get connectingToServers => 'Verbinden met servers...';
@override String get startingOfflineMode => 'Offlinemodus starten...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan';
@override String get showUnwatchedCount => 'Aantal ongekeken tonen';
@override String get showUnwatchedCountDescription => 'Toon aantal ongekeken afleveringen bij series en seizoenen';
@override String get hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen';
@override String get hideSpoilersDescription => 'Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien';
@override String get playerBackend => 'Speler backend';
@override String get exoPlayer => 'ExoPlayer (Aanbevolen)';
@override String get exoPlayerDescription => 'Android-native speler met betere hardware-ondersteuning';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Ga naar seizoen';
@override String get shufflePlay => 'Willekeurig afspelen';
@override String get fileInfo => 'Bestand info';
@override String get confirmDelete => 'Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?';
@override String get deleteMultipleWarning => 'Meerdere items kunnen worden verwijderd.';
@override String get deleteFromServer => 'Verwijderen van server';
@override String get confirmDelete => 'Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.';
@override String get deleteMultipleWarning => 'Dit omvat alle afleveringen en hun bestanden.';
@override String get mediaDeletedSuccessfully => 'Media-item succesvol verwijderd';
@override String get mediaFailedToDelete => 'Verwijderen van media-item mislukt';
@override String get rate => 'Beoordelen';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsNl implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audio-uitvoer';
@override String get performanceOverlay => 'Prestatie-overlay';
@override String get audioPassthrough => 'Audio-doorvoer';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
final TranslationsNl _root; // ignore: unused_field
// Translations
@override String get recent => 'Recent';
@override String get scan => 'Scannen';
@override String get manual => 'Handmatig';
@override String get recentConnections => 'Recente verbindingen';
@override String get quickReconnect => 'Snel opnieuw verbinden met eerder gekoppelde apparaten';
@override String get pairWithDesktop => 'Koppelen met desktop';
@override String get enterSessionDetails => 'Voer de sessiegegevens in die op je desktop-apparaat worden getoond';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.';
@override String cameraError({required Object error}) => 'Kan camera niet starten: ${error}';
@override String get scanInstruction => 'Richt je camera op de QR-code die op je desktop wordt getoond';
@override String get noRecentConnections => 'Geen recente verbindingen';
@override String get connectUsingManual => 'Verbind met een apparaat via Handmatige invoer om te beginnen';
@override String get invalidQrCode => 'Ongeldig QR-codeformaat';
@override String get removeRecentConnection => 'Recente verbinding verwijderen';
@override String removeConfirm({required Object name}) => '"${name}" verwijderen uit recente verbindingen?';
@override String get validationHostRequired => 'Voer een hostadres in';
@override String get validationHostFormat => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Voer een sessie-ID in';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Verbinding verlopen. Controleer de sessie-ID en PIN.';
@override String get sessionNotFound => 'Kan de sessie niet vinden. Controleer je gegevens.';
@override String failedToConnect({required Object error}) => 'Verbinden mislukt: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Kan recente sessies niet laden: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsNl {
'common.dontAskAgain' => 'Niet meer vragen',
'common.exit' => 'Afsluiten',
'common.viewAll' => 'Alles weergeven',
'common.checkingNetwork' => 'Netwerk controleren...',
'common.refreshingServers' => 'Servers vernieuwen...',
'common.loadingServers' => 'Servers laden...',
'common.connectingToServers' => 'Verbinden met servers...',
'common.startingOfflineMode' => 'Offlinemodus starten...',
'screens.licenses' => 'Licenties',
'screens.switchProfile' => 'Wissel van profiel',
'screens.subtitleStyling' => 'Ondertitel opmaak',
@@ -1401,6 +1407,8 @@ extension on TranslationsNl {
'settings.alwaysKeepSidebarOpenDescription' => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan',
'settings.showUnwatchedCount' => 'Aantal ongekeken tonen',
'settings.showUnwatchedCountDescription' => 'Toon aantal ongekeken afleveringen bij series en seizoenen',
'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen',
'settings.hideSpoilersDescription' => 'Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien',
'settings.playerBackend' => 'Speler backend',
'settings.exoPlayer' => 'ExoPlayer (Aanbevolen)',
'settings.exoPlayerDescription' => 'Android-native speler met betere hardware-ondersteuning',
@@ -1538,8 +1546,9 @@ extension on TranslationsNl {
'mediaMenu.goToSeason' => 'Ga naar seizoen',
'mediaMenu.shufflePlay' => 'Willekeurig afspelen',
'mediaMenu.fileInfo' => 'Bestand info',
'mediaMenu.confirmDelete' => 'Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?',
'mediaMenu.deleteMultipleWarning' => 'Meerdere items kunnen worden verwijderd.',
'mediaMenu.deleteFromServer' => 'Verwijderen van server',
'mediaMenu.confirmDelete' => 'Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.',
'mediaMenu.deleteMultipleWarning' => 'Dit omvat alle afleveringen en hun bestanden.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media-item succesvol verwijderd',
'mediaMenu.mediaFailedToDelete' => 'Verwijderen van media-item mislukt',
'mediaMenu.rate' => 'Beoordelen',
@@ -1949,11 +1958,8 @@ extension on TranslationsNl {
'companionRemote.session.copyToClipboard' => 'Kopieer naar klembord',
'companionRemote.session.newSession' => 'Nieuwe sessie',
'companionRemote.session.minimize' => 'Minimaliseren',
'companionRemote.pairing.recent' => 'Recent',
'companionRemote.pairing.scan' => 'Scannen',
'companionRemote.pairing.manual' => 'Handmatig',
'companionRemote.pairing.recentConnections' => 'Recente verbindingen',
'companionRemote.pairing.quickReconnect' => 'Snel opnieuw verbinden met eerder gekoppelde apparaten',
'companionRemote.pairing.pairWithDesktop' => 'Koppelen met desktop',
'companionRemote.pairing.enterSessionDetails' => 'Voer de sessiegegevens in die op je desktop-apparaat worden getoond',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsNl {
'companionRemote.pairing.cameraPermissionRequired' => 'Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kan camera niet starten: ${error}',
'companionRemote.pairing.scanInstruction' => 'Richt je camera op de QR-code die op je desktop wordt getoond',
'companionRemote.pairing.noRecentConnections' => 'Geen recente verbindingen',
'companionRemote.pairing.connectUsingManual' => 'Verbind met een apparaat via Handmatige invoer om te beginnen',
'companionRemote.pairing.invalidQrCode' => 'Ongeldig QR-codeformaat',
'companionRemote.pairing.removeRecentConnection' => 'Recente verbinding verwijderen',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}" verwijderen uit recente verbindingen?',
'companionRemote.pairing.validationHostRequired' => 'Voer een hostadres in',
'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Voer een sessie-ID in',
@@ -1981,7 +1983,6 @@ extension on TranslationsNl {
'companionRemote.pairing.connectionTimedOut' => 'Verbinding verlopen. Controleer de sessie-ID en PIN.',
'companionRemote.pairing.sessionNotFound' => 'Kan de sessie niet vinden. Controleer je gegevens.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbinden mislukt: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Kan recente sessies niet laden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?',
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Poging ${current} van 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsNl {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio-uitvoer',
'videoSettings.performanceOverlay' => 'Prestatie-overlay',
'videoSettings.audioPassthrough' => 'Audio-doorvoer',
'externalPlayer.title' => 'Externe speler',
'externalPlayer.useExternalPlayer' => 'Externe speler gebruiken',
'externalPlayer.useExternalPlayerDescription' => 'Open video\'s in een externe app in plaats van de ingebouwde speler',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonSv implements TranslationsCommonEn {
@override String get dontAskAgain => 'Fråga inte igen';
@override String get exit => 'Avsluta';
@override String get viewAll => 'Visa alla';
@override String get checkingNetwork => 'Kontrollerar nätverk...';
@override String get refreshingServers => 'Uppdaterar servrar...';
@override String get loadingServers => 'Laddar servrar...';
@override String get connectingToServers => 'Ansluter till servrar...';
@override String get startingOfflineMode => 'Startar offlineläge...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Sidofältet förblir expanderat och innehållsytan anpassas';
@override String get showUnwatchedCount => 'Visa antal osedda';
@override String get showUnwatchedCountDescription => 'Visa antal osedda avsnitt för serier och säsonger';
@override String get hideSpoilers => 'Dölj spoilers för osedda avsnitt';
@override String get hideSpoilersDescription => 'Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu';
@override String get playerBackend => 'Spelarmotor';
@override String get exoPlayer => 'ExoPlayer (Rekommenderad)';
@override String get exoPlayerDescription => 'Android-nativ spelare med bättre hårdvarustöd';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Gå till säsong';
@override String get shufflePlay => 'Blanda uppspelning';
@override String get fileInfo => 'Filinformation';
@override String get confirmDelete => 'Är du säker på att du vill ta bort detta objekt från ditt filsystem?';
@override String get deleteMultipleWarning => 'Flera objekt kan komma att tas bort.';
@override String get deleteFromServer => 'Ta bort från servern';
@override String get confirmDelete => 'Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.';
@override String get deleteMultipleWarning => 'Detta inkluderar alla avsnitt och deras filer.';
@override String get mediaDeletedSuccessfully => 'Mediaobjekt borttaget';
@override String get mediaFailedToDelete => 'Kunde inte ta bort mediaobjekt';
@override String get rate => 'Betygsätt';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsSv implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Ljudutgång';
@override String get performanceOverlay => 'Prestandaöverlägg';
@override String get audioPassthrough => 'Ljudgenomkoppling';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get recent => 'Senaste';
@override String get scan => 'Skanna';
@override String get manual => 'Manuell';
@override String get recentConnections => 'Senaste anslutningar';
@override String get quickReconnect => 'Återanslut snabbt till tidigare parkopplade enheter';
@override String get pairWithDesktop => 'Parkoppla med dator';
@override String get enterSessionDetails => 'Ange sessionsuppgifterna som visas på din datorenhet';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.';
@override String cameraError({required Object error}) => 'Kunde inte starta kameran: ${error}';
@override String get scanInstruction => 'Rikta kameran mot QR-koden som visas på din dator';
@override String get noRecentConnections => 'Inga senaste anslutningar';
@override String get connectUsingManual => 'Anslut till en enhet via Manuell inmatning för att komma igång';
@override String get invalidQrCode => 'Ogiltigt QR-kodformat';
@override String get removeRecentConnection => 'Ta bort senaste anslutning';
@override String removeConfirm({required Object name}) => 'Ta bort "${name}" från senaste anslutningar?';
@override String get validationHostRequired => 'Ange en värdadress';
@override String get validationHostFormat => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Ange ett sessions-ID';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.';
@override String get sessionNotFound => 'Kunde inte hitta sessionen. Kontrollera dina uppgifter.';
@override String failedToConnect({required Object error}) => 'Kunde inte ansluta: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Kunde inte ladda senaste sessioner: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsSv {
'common.dontAskAgain' => 'Fråga inte igen',
'common.exit' => 'Avsluta',
'common.viewAll' => 'Visa alla',
'common.checkingNetwork' => 'Kontrollerar nätverk...',
'common.refreshingServers' => 'Uppdaterar servrar...',
'common.loadingServers' => 'Laddar servrar...',
'common.connectingToServers' => 'Ansluter till servrar...',
'common.startingOfflineMode' => 'Startar offlineläge...',
'screens.licenses' => 'Licenser',
'screens.switchProfile' => 'Byt profil',
'screens.subtitleStyling' => 'Undertext-styling',
@@ -1401,6 +1407,8 @@ extension on TranslationsSv {
'settings.alwaysKeepSidebarOpenDescription' => 'Sidofältet förblir expanderat och innehållsytan anpassas',
'settings.showUnwatchedCount' => 'Visa antal osedda',
'settings.showUnwatchedCountDescription' => 'Visa antal osedda avsnitt för serier och säsonger',
'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt',
'settings.hideSpoilersDescription' => 'Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu',
'settings.playerBackend' => 'Spelarmotor',
'settings.exoPlayer' => 'ExoPlayer (Rekommenderad)',
'settings.exoPlayerDescription' => 'Android-nativ spelare med bättre hårdvarustöd',
@@ -1538,8 +1546,9 @@ extension on TranslationsSv {
'mediaMenu.goToSeason' => 'Gå till säsong',
'mediaMenu.shufflePlay' => 'Blanda uppspelning',
'mediaMenu.fileInfo' => 'Filinformation',
'mediaMenu.confirmDelete' => 'Är du säker på att du vill ta bort detta objekt från ditt filsystem?',
'mediaMenu.deleteMultipleWarning' => 'Flera objekt kan komma att tas bort.',
'mediaMenu.deleteFromServer' => 'Ta bort från servern',
'mediaMenu.confirmDelete' => 'Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.',
'mediaMenu.deleteMultipleWarning' => 'Detta inkluderar alla avsnitt och deras filer.',
'mediaMenu.mediaDeletedSuccessfully' => 'Mediaobjekt borttaget',
'mediaMenu.mediaFailedToDelete' => 'Kunde inte ta bort mediaobjekt',
'mediaMenu.rate' => 'Betygsätt',
@@ -1949,11 +1958,8 @@ extension on TranslationsSv {
'companionRemote.session.copyToClipboard' => 'Kopiera till urklipp',
'companionRemote.session.newSession' => 'Ny session',
'companionRemote.session.minimize' => 'Minimera',
'companionRemote.pairing.recent' => 'Senaste',
'companionRemote.pairing.scan' => 'Skanna',
'companionRemote.pairing.manual' => 'Manuell',
'companionRemote.pairing.recentConnections' => 'Senaste anslutningar',
'companionRemote.pairing.quickReconnect' => 'Återanslut snabbt till tidigare parkopplade enheter',
'companionRemote.pairing.pairWithDesktop' => 'Parkoppla med dator',
'companionRemote.pairing.enterSessionDetails' => 'Ange sessionsuppgifterna som visas på din datorenhet',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsSv {
'companionRemote.pairing.cameraPermissionRequired' => 'Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kunde inte starta kameran: ${error}',
'companionRemote.pairing.scanInstruction' => 'Rikta kameran mot QR-koden som visas på din dator',
'companionRemote.pairing.noRecentConnections' => 'Inga senaste anslutningar',
'companionRemote.pairing.connectUsingManual' => 'Anslut till en enhet via Manuell inmatning för att komma igång',
'companionRemote.pairing.invalidQrCode' => 'Ogiltigt QR-kodformat',
'companionRemote.pairing.removeRecentConnection' => 'Ta bort senaste anslutning',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Ta bort "${name}" från senaste anslutningar?',
'companionRemote.pairing.validationHostRequired' => 'Ange en värdadress',
'companionRemote.pairing.validationHostFormat' => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Ange ett sessions-ID',
@@ -1981,7 +1983,6 @@ extension on TranslationsSv {
'companionRemote.pairing.connectionTimedOut' => 'Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.',
'companionRemote.pairing.sessionNotFound' => 'Kunde inte hitta sessionen. Kontrollera dina uppgifter.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Kunde inte ladda senaste sessioner: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?',
'companionRemote.remote.reconnecting' => 'Återansluter...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Försök ${current} av 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsSv {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Ljudutgång',
'videoSettings.performanceOverlay' => 'Prestandaöverlägg',
'videoSettings.audioPassthrough' => 'Ljudgenomkoppling',
'externalPlayer.title' => 'Extern spelare',
'externalPlayer.useExternalPlayer' => 'Använd extern spelare',
'externalPlayer.useExternalPlayerDescription' => 'Öppna videor i en extern app istället för den inbyggda spelaren',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonZh implements TranslationsCommonEn {
@override String get dontAskAgain => '不再询问';
@override String get exit => '退出';
@override String get viewAll => '查看全部';
@override String get checkingNetwork => '正在检查网络...';
@override String get refreshingServers => '正在刷新服务器...';
@override String get loadingServers => '正在加载服务器...';
@override String get connectingToServers => '正在连接服务器...';
@override String get startingOfflineMode => '正在启动离线模式...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => '侧边栏保持展开状态,内容区域自动调整';
@override String get showUnwatchedCount => '显示未观看数量';
@override String get showUnwatchedCountDescription => '在剧集和季上显示未观看的集数';
@override String get hideSpoilers => '隐藏未看剧集的剧透内容';
@override String get hideSpoilersDescription => '模糊未观看剧集的缩略图并隐藏其描述';
@override String get playerBackend => '播放器引擎';
@override String get exoPlayer => 'ExoPlayer(推荐)';
@override String get exoPlayerDescription => 'Android 原生播放器,硬件支持更好';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn {
@override String get goToSeason => '转到季';
@override String get shufflePlay => '随机播放';
@override String get fileInfo => '文件信息';
@override String get confirmDelete => '确定要从文件系统中删除此项吗?';
@override String get deleteMultipleWarning => '可能会删除多个项目';
@override String get deleteFromServer => '从服务器删除';
@override String get confirmDelete => '这将永久删除此媒体及其文件。此操作无法撤销';
@override String get deleteMultipleWarning => '这包括所有剧集及其文件。';
@override String get mediaDeletedSuccessfully => '媒体项已成功删除';
@override String get mediaFailedToDelete => '删除媒体项失败';
@override String get rate => '评分';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => '音频输出';
@override String get performanceOverlay => '性能监控';
@override String get audioPassthrough => '音频直通';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
final TranslationsZh _root; // ignore: unused_field
// Translations
@override String get recent => '最近';
@override String get scan => '扫描';
@override String get manual => '手动';
@override String get recentConnections => '最近连接';
@override String get quickReconnect => '快速重新连接之前配对的设备';
@override String get pairWithDesktop => '与桌面配对';
@override String get enterSessionDetails => '输入桌面设备上显示的会话信息';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => '扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。';
@override String cameraError({required Object error}) => '无法启动相机:${error}';
@override String get scanInstruction => '将相机对准桌面上显示的 QR 码';
@override String get noRecentConnections => '没有最近的连接';
@override String get connectUsingManual => '使用手动输入连接设备以开始使用';
@override String get invalidQrCode => '无效的 QR 码格式';
@override String get removeRecentConnection => '删除最近连接';
@override String removeConfirm({required Object name}) => '确定要从最近连接中删除 "${name}" 吗?';
@override String get validationHostRequired => '请输入主机地址';
@override String get validationHostFormat => '格式必须为 IP:端口(例如 192.168.1.100:48632';
@override String get validationSessionIdRequired => '请输入会话 ID';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
@override String get connectionTimedOut => '连接超时。请检查会话 ID 和 PIN。';
@override String get sessionNotFound => '找不到会话。请检查您的凭据。';
@override String failedToConnect({required Object error}) => '连接失败:${error}';
@override String failedToLoadRecent({required Object error}) => '加载最近会话失败:${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsZh {
'common.dontAskAgain' => '不再询问',
'common.exit' => '退出',
'common.viewAll' => '查看全部',
'common.checkingNetwork' => '正在检查网络...',
'common.refreshingServers' => '正在刷新服务器...',
'common.loadingServers' => '正在加载服务器...',
'common.connectingToServers' => '正在连接服务器...',
'common.startingOfflineMode' => '正在启动离线模式...',
'screens.licenses' => '许可证',
'screens.switchProfile' => '切换用户',
'screens.subtitleStyling' => '字幕样式',
@@ -1401,6 +1407,8 @@ extension on TranslationsZh {
'settings.alwaysKeepSidebarOpenDescription' => '侧边栏保持展开状态,内容区域自动调整',
'settings.showUnwatchedCount' => '显示未观看数量',
'settings.showUnwatchedCountDescription' => '在剧集和季上显示未观看的集数',
'settings.hideSpoilers' => '隐藏未看剧集的剧透内容',
'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图并隐藏其描述',
'settings.playerBackend' => '播放器引擎',
'settings.exoPlayer' => 'ExoPlayer(推荐)',
'settings.exoPlayerDescription' => 'Android 原生播放器,硬件支持更好',
@@ -1538,8 +1546,9 @@ extension on TranslationsZh {
'mediaMenu.goToSeason' => '转到季',
'mediaMenu.shufflePlay' => '随机播放',
'mediaMenu.fileInfo' => '文件信息',
'mediaMenu.confirmDelete' => '确定要从文件系统中删除此项吗?',
'mediaMenu.deleteMultipleWarning' => '可能会删除多个项目',
'mediaMenu.deleteFromServer' => '从服务器删除',
'mediaMenu.confirmDelete' => '这将永久删除此媒体及其文件。此操作无法撤销',
'mediaMenu.deleteMultipleWarning' => '这包括所有剧集及其文件。',
'mediaMenu.mediaDeletedSuccessfully' => '媒体项已成功删除',
'mediaMenu.mediaFailedToDelete' => '删除媒体项失败',
'mediaMenu.rate' => '评分',
@@ -1949,11 +1958,8 @@ extension on TranslationsZh {
'companionRemote.session.copyToClipboard' => '复制到剪贴板',
'companionRemote.session.newSession' => '新建会话',
'companionRemote.session.minimize' => '最小化',
'companionRemote.pairing.recent' => '最近',
'companionRemote.pairing.scan' => '扫描',
'companionRemote.pairing.manual' => '手动',
'companionRemote.pairing.recentConnections' => '最近连接',
'companionRemote.pairing.quickReconnect' => '快速重新连接之前配对的设备',
'companionRemote.pairing.pairWithDesktop' => '与桌面配对',
'companionRemote.pairing.enterSessionDetails' => '输入桌面设备上显示的会话信息',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsZh {
'companionRemote.pairing.cameraPermissionRequired' => '扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。',
'companionRemote.pairing.cameraError' => ({required Object error}) => '无法启动相机:${error}',
'companionRemote.pairing.scanInstruction' => '将相机对准桌面上显示的 QR 码',
'companionRemote.pairing.noRecentConnections' => '没有最近的连接',
'companionRemote.pairing.connectUsingManual' => '使用手动输入连接设备以开始使用',
'companionRemote.pairing.invalidQrCode' => '无效的 QR 码格式',
'companionRemote.pairing.removeRecentConnection' => '删除最近连接',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '确定要从最近连接中删除 "${name}" 吗?',
'companionRemote.pairing.validationHostRequired' => '请输入主机地址',
'companionRemote.pairing.validationHostFormat' => '格式必须为 IP:端口(例如 192.168.1.100:48632',
'companionRemote.pairing.validationSessionIdRequired' => '请输入会话 ID',
@@ -1981,7 +1983,6 @@ extension on TranslationsZh {
'companionRemote.pairing.connectionTimedOut' => '连接超时。请检查会话 ID 和 PIN。',
'companionRemote.pairing.sessionNotFound' => '找不到会话。请检查您的凭据。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => '加载最近会话失败:${error}',
'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?',
'companionRemote.remote.reconnecting' => '重新连接中...',
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current} 次尝试,共 5 次',
@@ -2019,6 +2020,7 @@ extension on TranslationsZh {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音频输出',
'videoSettings.performanceOverlay' => '性能监控',
'videoSettings.audioPassthrough' => '音频直通',
'externalPlayer.title' => '外部播放器',
'externalPlayer.useExternalPlayer' => '使用外部播放器',
'externalPlayer.useExternalPlayerDescription' => '在外部应用中打开视频,而不是使用内置播放器',
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Är du säker på att du vill avsluta?",
"dontAskAgain": "Fråga inte igen",
"exit": "Avsluta",
"viewAll": "Visa alla"
"viewAll": "Visa alla",
"checkingNetwork": "Kontrollerar nätverk...",
"refreshingServers": "Uppdaterar servrar...",
"loadingServers": "Laddar servrar...",
"connectingToServers": "Ansluter till servrar...",
"startingOfflineMode": "Startar offlineläge..."
},
"screens": {
"licenses": "Licenser",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Sidofältet förblir expanderat och innehållsytan anpassas",
"showUnwatchedCount": "Visa antal osedda",
"showUnwatchedCountDescription": "Visa antal osedda avsnitt för serier och säsonger",
"hideSpoilers": "Dölj spoilers för osedda avsnitt",
"hideSpoilersDescription": "Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu",
"playerBackend": "Spelarmotor",
"exoPlayer": "ExoPlayer (Rekommenderad)",
"exoPlayerDescription": "Android-nativ spelare med bättre hårdvarustöd",
@@ -266,8 +273,9 @@
"goToSeason": "Gå till säsong",
"shufflePlay": "Blanda uppspelning",
"fileInfo": "Filinformation",
"confirmDelete": "Är du säker på att du vill ta bort detta objekt från ditt filsystem?",
"deleteMultipleWarning": "Flera objekt kan komma att tas bort.",
"deleteFromServer": "Ta bort från servern",
"confirmDelete": "Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.",
"deleteMultipleWarning": "Detta inkluderar alla avsnitt och deras filer.",
"mediaDeletedSuccessfully": "Mediaobjekt borttaget",
"mediaFailedToDelete": "Kunde inte ta bort mediaobjekt",
"rate": "Betygsätt"
@@ -732,11 +740,8 @@
"minimize": "Minimera"
},
"pairing": {
"recent": "Senaste",
"scan": "Skanna",
"manual": "Manuell",
"recentConnections": "Senaste anslutningar",
"quickReconnect": "Återanslut snabbt till tidigare parkopplade enheter",
"pairWithDesktop": "Parkoppla med dator",
"enterSessionDetails": "Ange sessionsuppgifterna som visas på din datorenhet",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.",
"cameraError": "Kunde inte starta kameran: ${error}",
"scanInstruction": "Rikta kameran mot QR-koden som visas på din dator",
"noRecentConnections": "Inga senaste anslutningar",
"connectUsingManual": "Anslut till en enhet via Manuell inmatning för att komma igång",
"invalidQrCode": "Ogiltigt QR-kodformat",
"removeRecentConnection": "Ta bort senaste anslutning",
"removeConfirm": "Ta bort \"${name}\" från senaste anslutningar?",
"validationHostRequired": "Ange en värdadress",
"validationHostFormat": "Format måste vara IP:port (t.ex. 192.168.1.100:48632)",
"validationSessionIdRequired": "Ange ett sessions-ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN måste vara 6 siffror",
"connectionTimedOut": "Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.",
"sessionNotFound": "Kunde inte hitta sessionen. Kontrollera dina uppgifter.",
"failedToConnect": "Kunde inte ansluta: ${error}",
"failedToLoadRecent": "Kunde inte ladda senaste sessioner: ${error}"
"failedToConnect": "Kunde inte ansluta: ${error}"
},
"remote": {
"disconnectConfirm": "Vill du koppla från fjärrsessionen?",
@@ -806,7 +806,8 @@
"subtitleSync": "Undertextsynkronisering",
"hdr": "HDR",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg"
"performanceOverlay": "Prestandaöverlägg",
"audioPassthrough": "Ljudgenomkoppling"
},
"externalPlayer": {
"title": "Extern spelare",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "确定要退出吗?",
"dontAskAgain": "不再询问",
"exit": "退出",
"viewAll": "查看全部"
"viewAll": "查看全部",
"checkingNetwork": "正在检查网络...",
"refreshingServers": "正在刷新服务器...",
"loadingServers": "正在加载服务器...",
"connectingToServers": "正在连接服务器...",
"startingOfflineMode": "正在启动离线模式..."
},
"screens": {
"licenses": "许可证",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整",
"showUnwatchedCount": "显示未观看数量",
"showUnwatchedCountDescription": "在剧集和季上显示未观看的集数",
"hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图并隐藏其描述",
"playerBackend": "播放器引擎",
"exoPlayer": "ExoPlayer(推荐)",
"exoPlayerDescription": "Android 原生播放器,硬件支持更好",
@@ -266,8 +273,9 @@
"goToSeason": "转到季",
"shufflePlay": "随机播放",
"fileInfo": "文件信息",
"confirmDelete": "确定要从文件系统中删除此项吗?",
"deleteMultipleWarning": "可能会删除多个项目。",
"deleteFromServer": "从服务器删除",
"confirmDelete": "这将永久删除此媒体及其文件。此操作无法撤销。",
"deleteMultipleWarning": "这包括所有剧集及其文件。",
"mediaDeletedSuccessfully": "媒体项已成功删除",
"mediaFailedToDelete": "删除媒体项失败",
"rate": "评分"
@@ -732,11 +740,8 @@
"minimize": "最小化"
},
"pairing": {
"recent": "最近",
"scan": "扫描",
"manual": "手动",
"recentConnections": "最近连接",
"quickReconnect": "快速重新连接之前配对的设备",
"pairWithDesktop": "与桌面配对",
"enterSessionDetails": "输入桌面设备上显示的会话信息",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。",
"cameraError": "无法启动相机:${error}",
"scanInstruction": "将相机对准桌面上显示的 QR 码",
"noRecentConnections": "没有最近的连接",
"connectUsingManual": "使用手动输入连接设备以开始使用",
"invalidQrCode": "无效的 QR 码格式",
"removeRecentConnection": "删除最近连接",
"removeConfirm": "确定要从最近连接中删除 \"${name}\" 吗?",
"validationHostRequired": "请输入主机地址",
"validationHostFormat": "格式必须为 IP:端口(例如 192.168.1.100:48632",
"validationSessionIdRequired": "请输入会话 ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN 必须为6位数字",
"connectionTimedOut": "连接超时。请检查会话 ID 和 PIN。",
"sessionNotFound": "找不到会话。请检查您的凭据。",
"failedToConnect": "连接失败:${error}",
"failedToLoadRecent": "加载最近会话失败:${error}"
"failedToConnect": "连接失败:${error}"
},
"remote": {
"disconnectConfirm": "是否要断开远程会话的连接?",
@@ -806,7 +806,8 @@
"subtitleSync": "字幕同步",
"hdr": "HDR",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控"
"performanceOverlay": "性能监控",
"audioPassthrough": "音频直通"
},
"externalPlayer": {
"title": "外部播放器",
+72 -32
View File
@@ -4,10 +4,11 @@ import 'package:flutter/gestures.dart';
import 'dart:io' show Platform;
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'screens/main_screen.dart';
import 'screens/auth_screen.dart';
import 'services/storage_service.dart';
import 'services/macos_titlebar_service.dart';
import 'services/macos_window_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'services/settings_service.dart';
import 'utils/platform_detector.dart';
@@ -46,6 +47,7 @@ import 'i18n/strings.g.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'utils/navigation_transitions.dart';
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
// at (0,0) as barrier taps, causing modals to dismiss immediately.
@@ -79,7 +81,8 @@ void main() async {
await initializeDateFormatting(savedLocale.languageCode, null);
// Configure image cache for large libraries
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20; // 200MB
PaintingBinding.instance.imageCache.maximumSize = 2000; // default 1000
PaintingBinding.instance.imageCache.maximumSizeBytes = 300 << 20; // 300MB
// Initialize services in parallel where possible
final futures = <Future<void>>[];
@@ -97,7 +100,7 @@ void main() async {
}
// Configure macOS window with custom titlebar (depends on window manager)
futures.add(MacOSTitlebarService.setupCustomTitlebar());
futures.add(MacOSWindowService.setupCustomTitlebar());
// Initialize storage service
futures.add(StorageService.getInstance());
@@ -134,7 +137,7 @@ void main() async {
void _registerShaderLicenses() {
LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks(
yield const LicenseEntryWithLineBreaks(
['Anime4K'],
'MIT License\n'
'\n'
@@ -159,7 +162,7 @@ void _registerShaderLicenses() {
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE '
'SOFTWARE.',
);
yield LicenseEntryWithLineBreaks(
yield const LicenseEntryWithLineBreaks(
['NVIDIA Image Scaling (NVScaler)'],
'The MIT License (MIT)\n'
'\n'
@@ -367,31 +370,56 @@ class SetupScreen extends StatefulWidget {
}
class _SetupScreenState extends State<SetupScreen> {
String _statusMessage = '';
@override
void initState() {
super.initState();
_loadSavedCredentials();
}
void _setStatus(String message) {
if (mounted) setState(() => _statusMessage = message);
}
Future<void> _loadSavedCredentials() async {
_setStatus(t.common.checkingNetwork);
final storage = await StorageService.getInstance();
final registry = ServerRegistry(storage);
// Check network connectivity early to fast-path airplane mode
final connectivityResult = await Connectivity().checkConnectivity();
// Check network connectivity early to fast-path airplane mode.
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
final connectivityResult = await Connectivity().checkConnectivity().timeout(
const Duration(seconds: 3),
onTimeout: () => [ConnectivityResult.other],
);
final hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
if (hasNetwork) {
// Refresh servers from API to get updated connection info (IPs may change)
await registry.refreshServersFromApi();
_setStatus(t.common.refreshingServers);
// Refresh servers from API to get updated connection info (IPs may change).
// If the stored token is invalid (e.g. after removing a Plex profile PIN),
// redirect to AuthScreen so the user can re-authenticate.
final refreshResult = await registry.refreshServersFromApi();
if (refreshResult == ServerRefreshResult.authError) {
await storage.clearCredentials();
if (mounted) {
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
}
return;
}
}
_setStatus(t.common.loadingServers);
// Load all configured servers
final servers = await registry.getServers();
if (servers.isEmpty) {
if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const AuthScreen()));
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
}
return;
}
@@ -400,15 +428,15 @@ class _SetupScreenState extends State<SetupScreen> {
// No network — skip connection attempts and go straight to offline mode
if (!hasNetwork) {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
return;
}
_setStatus(t.common.connectingToServers);
try {
final result = await ServerConnectionOrchestrator.connectAndInitialize(
servers: servers,
@@ -427,40 +455,52 @@ class _SetupScreenState extends State<SetupScreen> {
downloadProvider.resumeQueuedDownloads(result.firstClient!);
});
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)),
);
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
} else {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
}
} catch (e, stackTrace) {
appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace);
if (mounted) {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [const CircularProgressIndicator(), const SizedBox(height: 16), Text(t.common.loading)],
),
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Stack(
children: [
// Icon dead-center, matching Android 12+ splash position.
// 192dp accounts for the 16% inset in ic_launcher.xml.
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
// Status text below center, independent of icon position.
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).size.height * 0.5 - 140,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_statusMessage,
key: ValueKey(_statusMessage),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
),
),
),
],
),
);
}
@@ -1,55 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'recent_remote_session.g.dart';
/// Recent Companion Remote session for quick reconnection
@JsonSerializable()
class RecentRemoteSession {
final String sessionId;
final String pin;
final String deviceName;
final String platform;
final DateTime lastConnected;
final String? hostAddress; // Format: "ip:port"
RecentRemoteSession({
required this.sessionId,
required this.pin,
required this.deviceName,
required this.platform,
required this.lastConnected,
this.hostAddress,
});
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) => _$RecentRemoteSessionFromJson(json);
Map<String, dynamic> toJson() => _$RecentRemoteSessionToJson(this);
/// Create from QR code data (format: "ip1,ip2|port|sessionId|pin" or legacy "ip|port|sessionId|pin")
factory RecentRemoteSession.fromQrData(String qrData) {
final parts = qrData.split('|');
if (parts.length < 4) {
throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin');
}
final ipsField = parts.first;
final port = parts[1];
final sessionId = parts[2];
final pin = parts[3];
// Use the first IP for storage (comma-separated IPs supported in QR)
final firstIp = ipsField.split(',').first;
return RecentRemoteSession(
sessionId: sessionId,
pin: pin,
deviceName: 'Unknown Device',
platform: 'unknown',
lastConnected: DateTime.now(),
hostAddress: '$firstIp:$port',
);
}
@override
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
}
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recent_remote_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecentRemoteSession _$RecentRemoteSessionFromJson(Map<String, dynamic> json) => RecentRemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
lastConnected: DateTime.parse(json['lastConnected'] as String),
hostAddress: json['hostAddress'] as String?,
);
Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) => <String, dynamic>{
'sessionId': instance.sessionId,
'pin': instance.pin,
'deviceName': instance.deviceName,
'platform': instance.platform,
'lastConnected': instance.lastConnected.toIso8601String(),
'hostAddress': instance.hostAddress,
};
@@ -1,4 +1,56 @@
import 'remote_command_type.dart';
enum RemoteCommandType {
// Navigation
dpadUp,
dpadDown,
dpadLeft,
dpadRight,
select,
back,
contextMenu,
// Playback
play,
pause,
playPause,
stop,
seekForward,
seekBackward,
nextTrack,
previousTrack,
skipIntro,
skipCredits,
// Volume
volumeUp,
volumeDown,
volumeMute,
volumeSet,
// Tab Navigation
tabNext,
tabPrevious,
tabDiscover,
tabLibraries,
tabSearch,
tabDownloads,
tabSettings,
// Quick Actions
home,
search,
subtitles,
audioTracks,
qualitySettings,
fullscreen,
// Session Management
ping,
pong,
deviceInfo,
disconnect,
ack,
syncState,
}
class RemoteCommand {
final RemoteCommandType type;
@@ -1,53 +0,0 @@
enum RemoteCommandType {
// Navigation
dpadUp,
dpadDown,
dpadLeft,
dpadRight,
select,
back,
contextMenu,
// Playback
play,
pause,
playPause,
stop,
seekForward,
seekBackward,
nextTrack,
previousTrack,
skipIntro,
skipCredits,
// Volume
volumeUp,
volumeDown,
volumeMute,
volumeSet,
// Tab Navigation
tabNext,
tabPrevious,
tabDiscover,
tabLibraries,
tabSearch,
tabDownloads,
tabSettings,
// Quick Actions
home,
search,
subtitles,
audioTracks,
qualitySettings,
fullscreen,
// Session Management
ping,
pong,
deviceInfo,
disconnect,
ack,
syncState,
}
@@ -1,54 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'trusted_device.g.dart';
@JsonSerializable()
class TrustedDevice {
final String peerId;
final String deviceName;
final String platform;
final DateTime firstConnected;
final DateTime lastConnected;
final bool isApproved;
TrustedDevice({
required this.peerId,
required this.deviceName,
required this.platform,
DateTime? firstConnected,
DateTime? lastConnected,
this.isApproved = false,
}) : firstConnected = firstConnected ?? DateTime.now(),
lastConnected = lastConnected ?? DateTime.now();
factory TrustedDevice.fromJson(Map<String, dynamic> json) => _$TrustedDeviceFromJson(json);
Map<String, dynamic> toJson() => _$TrustedDeviceToJson(this);
TrustedDevice copyWith({
String? peerId,
String? deviceName,
String? platform,
DateTime? firstConnected,
DateTime? lastConnected,
bool? isApproved,
}) {
return TrustedDevice(
peerId: peerId ?? this.peerId,
deviceName: deviceName ?? this.deviceName,
platform: platform ?? this.platform,
firstConnected: firstConnected ?? this.firstConnected,
lastConnected: lastConnected ?? this.lastConnected,
isApproved: isApproved ?? this.isApproved,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TrustedDevice && other.peerId == peerId;
}
@override
int get hashCode => peerId.hashCode;
}
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trusted_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TrustedDevice _$TrustedDeviceFromJson(Map<String, dynamic> json) => TrustedDevice(
peerId: json['peerId'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
firstConnected: json['firstConnected'] == null ? null : DateTime.parse(json['firstConnected'] as String),
lastConnected: json['lastConnected'] == null ? null : DateTime.parse(json['lastConnected'] as String),
isApproved: json['isApproved'] as bool? ?? false,
);
Map<String, dynamic> _$TrustedDeviceToJson(TrustedDevice instance) => <String, dynamic>{
'peerId': instance.peerId,
'deviceName': instance.deviceName,
'platform': instance.platform,
'firstConnected': instance.firstConnected.toIso8601String(),
'lastConnected': instance.lastConnected.toIso8601String(),
'isApproved': instance.isApproved,
};
+59 -1
View File
@@ -1,3 +1,4 @@
import '../utils/app_logger.dart';
import '../utils/codec_utils.dart';
class PlexMediaInfo {
@@ -15,6 +16,63 @@ class PlexMediaInfo {
this.partId,
});
int? getPartId() => partId;
/// Creates a [PlexMediaInfo] from cached metadata JSON (as stored by [PlexApiCache]).
/// Parses audio/subtitle tracks from `Media[0].Part[0].Stream[]` so that
/// offline playback can still apply language-based track selection.
static PlexMediaInfo? fromMetadataJson(Map<String, dynamic> metadata) {
final media = metadata['Media'] as List<dynamic>?;
if (media == null || media.isEmpty) return null;
final parts = media[0]['Part'] as List<dynamic>?;
if (parts == null || parts.isEmpty) return null;
final streams = parts[0]['Stream'] as List<dynamic>?;
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
if (streams != null) {
for (final s in streams) {
try {
final streamType = s['streamType'] as int?;
if (streamType == 2) {
audioTracks.add(PlexAudioTrack(
id: s['id'] as int,
index: s['index'] as int?,
codec: s['codec'] as String?,
language: s['language'] as String?,
languageCode: s['languageCode'] as String?,
title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?,
channels: s['channels'] as int?,
selected: s['selected'] == 1 || s['selected'] == true,
));
} else if (streamType == 3) {
subtitleTracks.add(PlexSubtitleTrack(
id: s['id'] as int,
index: s['index'] as int?,
codec: s['codec'] as String?,
language: s['language'] as String?,
languageCode: s['languageCode'] as String?,
title: s['title'] as String?,
displayTitle: s['displayTitle'] as String?,
selected: s['selected'] == 1 || s['selected'] == true,
forced: s['forced'] == 1,
key: s['key'] as String?,
));
}
} catch (e) {
appLogger.d('Skipping malformed stream in cached metadata', error: e);
}
}
}
return PlexMediaInfo(
videoUrl: '',
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: const [],
);
}
}
/// Builds a track label from parts with the standard `' · '` joiner pattern.
@@ -173,7 +231,7 @@ class PlexMarker {
bool containsPosition(Duration position) {
final posMs = position.inMilliseconds;
return posMs >= startTimeOffset && posMs <= endTimeOffset;
return posMs >= startTimeOffset && posMs < endTimeOffset;
}
}
+29 -7
View File
@@ -92,6 +92,7 @@ class PlexMetadata with MultiServerFields {
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
final int? librarySectionID; // Library section ID this item belongs to
final String? librarySectionTitle; // Library section title this item belongs to
final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe)
final String? audienceRatingImage; // Audience rating source URI
final String? tagline;
@@ -111,6 +112,9 @@ class PlexMetadata with MultiServerFields {
// Clear logo URL (extracted from Image array, but serialized for offline storage)
final String? clearLogo;
// Square background art URL (extracted from Image array, used for near-square hero layouts)
final String? backgroundSquare;
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey;
@@ -174,6 +178,7 @@ class PlexMetadata with MultiServerFields {
this.playlistItemID,
this.playQueueItemID,
this.librarySectionID,
this.librarySectionTitle,
this.ratingImage,
this.audienceRatingImage,
this.tagline,
@@ -184,6 +189,7 @@ class PlexMetadata with MultiServerFields {
this.serverId,
this.serverName,
this.clearLogo,
this.backgroundSquare,
});
/// Create a copy of this metadata with optional field overrides
@@ -229,6 +235,7 @@ class PlexMetadata with MultiServerFields {
int? playlistItemID,
int? playQueueItemID,
int? librarySectionID,
String? librarySectionTitle,
String? ratingImage,
String? audienceRatingImage,
String? tagline,
@@ -239,6 +246,7 @@ class PlexMetadata with MultiServerFields {
String? serverId,
String? serverName,
String? clearLogo,
String? backgroundSquare,
}) {
return PlexMetadata(
ratingKey: ratingKey ?? this.ratingKey,
@@ -282,6 +290,7 @@ class PlexMetadata with MultiServerFields {
playlistItemID: playlistItemID ?? this.playlistItemID,
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle,
ratingImage: ratingImage ?? this.ratingImage,
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
tagline: tagline ?? this.tagline,
@@ -292,35 +301,48 @@ class PlexMetadata with MultiServerFields {
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
clearLogo: clearLogo ?? this.clearLogo,
backgroundSquare: backgroundSquare ?? this.backgroundSquare,
);
}
/// Extract clearLogo from Image array in raw JSON
static String? _extractClearLogoFromJson(Map<String, dynamic> json) {
/// Extract an image URL by type from the Image array in raw JSON
static String? _extractImageFromJson(Map<String, dynamic> json, String imageType) {
if (!json.containsKey('Image')) return null;
final images = json['Image'] as List?;
if (images == null) return null;
for (var image in images) {
if (image is Map && image['type'] == 'clearLogo') {
if (image is Map && image['type'] == imageType) {
return image['url'] as String?;
}
}
return null;
}
/// Create from JSON with clearLogo extracted from Image array
/// Create from JSON with Image array fields extracted
factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) {
// Extract clearLogo before parsing
final clearLogoUrl = _extractClearLogoFromJson(json);
// Add it to the json so it gets parsed
final clearLogoUrl = _extractImageFromJson(json, 'clearLogo');
if (clearLogoUrl != null) {
json['clearLogo'] = clearLogoUrl;
}
final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare');
if (backgroundSquareUrl != null) {
json['backgroundSquare'] = backgroundSquareUrl;
}
return PlexMetadata.fromJson(json);
}
/// Returns the best hero art path based on the container's aspect ratio.
/// Uses backgroundSquare when the container is closer to 1:1 than 16:9.
String? heroArt({required double containerAspectRatio}) {
// Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39
if (containerAspectRatio < 1.39 && backgroundSquare != null) {
return backgroundSquare;
}
return art;
}
// Helper to get the display title (show name for episodes/seasons, title otherwise)
String get displayTitle {
final itemType = type.toLowerCase();
+4
View File
@@ -48,6 +48,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
@@ -56,6 +57,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
extraType: (json['extraType'] as num?)?.toInt(),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
@@ -100,6 +102,7 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'librarySectionTitle': instance.librarySectionTitle,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'tagline': instance.tagline,
@@ -108,4 +111,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'extraType': instance.extraType,
'primaryExtraKey': instance.primaryExtraKey,
'clearLogo': instance.clearLogo,
'backgroundSquare': instance.backgroundSquare,
};
@@ -1,7 +1,7 @@
import 'package:flutter/services.dart';
import '../models.dart';
import 'player_base.dart';
import '../../models.dart';
import '../player_base.dart';
/// Android implementation of [Player] using ExoPlayer.
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
@@ -36,12 +36,6 @@ class PlayerAndroid extends PlayerBase {
return;
}
if (name == 'memory-pressure') {
// System memory is critically low — playback may be at risk of OOM crash
errorController.add('Low memory — playback may be unstable');
return;
}
// Delegate to base class for common events
super.handlePlayerEvent(name, data);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import 'dart:io' show Platform;
import '../models.dart';
import 'player_android.dart';
import 'platform/player_android.dart';
import 'player_native.dart';
import 'player_state.dart';
import 'player_streams.dart';
+8 -4
View File
@@ -40,6 +40,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
bool _disposed = false;
final _throttleSw = Stopwatch()..start();
int _lastEmitMs = 0;
int _lastCacheStateMs = 0;
int _positionMs = 0;
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
@@ -173,6 +174,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'demuxer-cache-time':
if (value is num) {
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) break;
_lastCacheStateMs = nowMs;
final buffer = Duration(milliseconds: (value * 1000).toInt());
_state = _state.copyWith(buffer: buffer);
bufferController.add(buffer);
@@ -274,6 +278,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (value is Map) {
cacheState = value;
} else if (value is String && value.isNotEmpty) {
// Throttle JSON parsing to avoid ANR on low-end devices
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) return;
_lastCacheStateMs = nowMs;
try {
final parsed = jsonDecode(value);
if (parsed is Map) cacheState = parsed;
@@ -331,10 +339,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
break;
case 'playback-restart':
// Clear stale buffer ranges from before the seek; fresh ones will
// arrive shortly via the next demuxer-cache-state update.
_state = _state.copyWith(bufferRanges: const []);
bufferRangesController.add(const []);
playbackRestartController.add(null);
break;
+1 -2
View File
@@ -282,8 +282,7 @@ class PlayerNative extends PlayerBase {
Future<void> updateFrame() async {
checkDisposed();
if (!initialized) return;
// Only iOS and macOS use Metal layer that needs frame updates
if (Platform.isIOS || Platform.isMacOS) {
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
await methodChannel.invokeMethod('updateFrame');
}
}
+2 -191
View File
@@ -1,35 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import '../models/companion_remote/remote_command.dart';
import '../models/companion_remote/remote_command_type.dart';
import '../models/companion_remote/remote_session.dart';
import '../models/companion_remote/trusted_device.dart';
import '../services/companion_remote/companion_remote_peer_service.dart';
import '../models/companion_remote/recent_remote_session.dart';
import '../services/companion_remote/companion_remote_discovery_service.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
typedef CommandReceivedCallback = void Function(RemoteCommand command);
typedef DeviceApprovalCallback = Future<bool> Function(RemoteDevice device);
class CompanionRemoteProvider with ChangeNotifier {
RemoteSession? _session;
CompanionRemotePeerService? _peerService;
CompanionRemoteDiscoveryService? _discoveryService;
String _deviceName = 'Unknown Device';
String _platform = 'unknown';
final List<TrustedDevice> _trustedDevices = [];
final List<RecentRemoteSession> _recentSessions = [];
bool _isPlayerActive = false;
static const String _storageKey = 'companion_remote_trusted_devices';
static const String _lastDeviceKey = 'companion_remote_last_device';
static const int _maxReconnectAttempts = 5;
Timer? _reconnectTimer;
@@ -46,10 +34,8 @@ class CompanionRemoteProvider with ChangeNotifier {
StreamSubscription<void>? _deviceDisconnectedSubscription;
StreamSubscription<RemotePeerError>? _errorSubscription;
StreamSubscription<RemoteSessionStatus>? _statusSubscription;
StreamSubscription<List<RecentRemoteSession>>? _recentSessionsSubscription;
CommandReceivedCallback? onCommandReceived;
DeviceApprovalCallback? onDeviceApprovalRequired;
bool get isInSession => _session != null && _session!.status != RemoteSessionStatus.disconnected;
bool get isHost => _session?.isHost ?? false;
@@ -60,13 +46,10 @@ class CompanionRemoteProvider with ChangeNotifier {
String? get sessionId => _session?.sessionId;
String? get pin => _session?.pin;
RemoteDevice? get connectedDevice => _session?.connectedDevice;
List<TrustedDevice> get trustedDevices => List.unmodifiable(_trustedDevices);
List<RecentRemoteSession> get recentSessions => List.unmodifiable(_recentSessions);
bool get isPlayerActive => _isPlayerActive;
CompanionRemoteProvider() {
_initializeDeviceInfo();
_loadTrustedDevices();
}
Future<void> _initializeDeviceInfo() async {
@@ -123,12 +106,10 @@ class CompanionRemoteProvider with ChangeNotifier {
},
);
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) async {
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) {
appLogger.d('CompanionRemote: Device connected: ${device.name}');
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
notifyListeners();
await addTrustedDevice(device, requireApproval: isHost);
});
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
@@ -165,7 +146,7 @@ class CompanionRemoteProvider with ChangeNotifier {
});
}
Future<void> _handleDeviceInfo(RemoteCommand command) async {
void _handleDeviceInfo(RemoteCommand command) {
if (command.data != null) {
final id = command.data!['id'] as String? ?? 'unknown';
final name = command.data!['name'] as String? ?? 'Unknown Device';
@@ -178,9 +159,6 @@ class CompanionRemoteProvider with ChangeNotifier {
_session = _session?.copyWith(connectedDevice: device);
notifyListeners();
// Save to recent sessions now that we have the remote device's real identity
await _addToRecentSessions();
}
}
@@ -384,177 +362,10 @@ class CompanionRemoteProvider with ChangeNotifier {
notifyListeners();
}
Future<void> _loadTrustedDevices() async {
try {
final storage = await StorageService.getInstance();
final json = storage.prefs.getString(_storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_trustedDevices.clear();
_trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>)));
appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices');
}
} catch (e) {
appLogger.e('CompanionRemote: Failed to load trusted devices', error: e);
}
}
Future<void> _saveTrustedDevices() async {
try {
final storage = await StorageService.getInstance();
final json = jsonEncode(_trustedDevices.map((e) => e.toJson()).toList());
await storage.prefs.setString(_storageKey, json);
appLogger.d('CompanionRemote: Saved ${_trustedDevices.length} trusted devices');
} catch (e) {
appLogger.e('CompanionRemote: Failed to save trusted devices', error: e);
}
}
bool isDeviceTrusted(String peerId) {
return _trustedDevices.any((d) => d.peerId == peerId && d.isApproved);
}
Future<void> addTrustedDevice(RemoteDevice device, {bool requireApproval = true}) async {
final existing = _trustedDevices.where((d) => d.peerId == device.id).firstOrNull;
if (existing != null) {
final updated = existing.copyWith(
deviceName: device.name,
platform: device.platform,
lastConnected: DateTime.now(),
isApproved: !requireApproval || existing.isApproved,
);
_trustedDevices.remove(existing);
_trustedDevices.add(updated);
} else {
bool approved = !requireApproval;
if (requireApproval && onDeviceApprovalRequired != null) {
approved = await onDeviceApprovalRequired!(device);
}
_trustedDevices.add(
TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved),
);
}
await _saveTrustedDevices();
if (isRemote) {
final storage = await StorageService.getInstance();
await storage.prefs.setString(_lastDeviceKey, device.id);
}
notifyListeners();
}
Future<void> removeTrustedDevice(String peerId) async {
_trustedDevices.removeWhere((d) => d.peerId == peerId);
await _saveTrustedDevices();
notifyListeners();
}
Future<void> approveTrustedDevice(String peerId) async {
final device = _trustedDevices.where((d) => d.peerId == peerId).firstOrNull;
if (device != null) {
final updated = device.copyWith(isApproved: true);
_trustedDevices.remove(device);
_trustedDevices.add(updated);
await _saveTrustedDevices();
notifyListeners();
}
}
Future<String?> getLastConnectedDevicePeerId() async {
final storage = await StorageService.getInstance();
return storage.prefs.getString(_lastDeviceKey);
}
/// Load recent sessions
Future<void> loadRecentSessions() async {
try {
// Dispose previous discovery service and subscription to avoid leaks
_recentSessionsSubscription?.cancel();
_recentSessionsSubscription = null;
_discoveryService?.dispose();
_discoveryService = CompanionRemoteDiscoveryService();
// Listen for recent sessions updates
_recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) {
_recentSessions.clear();
_recentSessions.addAll(sessions);
notifyListeners();
});
// Initial load happens in constructor, just notify
_recentSessions.clear();
_recentSessions.addAll(_discoveryService!.currentSessions);
notifyListeners();
appLogger.d('CompanionRemote: Loaded ${_recentSessions.length} recent sessions');
} catch (e) {
appLogger.e('CompanionRemote: Failed to load recent sessions', error: e);
}
}
/// Add current session to recent list (called after successful connection)
Future<void> _addToRecentSessions() async {
if (_session == null || _session!.sessionId.isEmpty) return;
// For mobile (remote role), save the connected desktop device
// For desktop (host role), this doesn't really apply but save connected mobile device
final deviceToSave = _session!.connectedDevice;
if (deviceToSave == null) {
appLogger.w('CompanionRemote: No connected device to save to recent sessions');
return;
}
final recentSession = RecentRemoteSession(
sessionId: _session!.sessionId,
pin: _session!.pin,
deviceName: deviceToSave.name,
platform: deviceToSave.platform,
lastConnected: DateTime.now(),
hostAddress: _peerService?.hostAddress,
);
if (_discoveryService != null) {
await _discoveryService!.addRecentSession(recentSession);
}
}
/// Connect to a recent session
Future<void> connectToRecentSession(RecentRemoteSession session) async {
if (session.hostAddress == null) {
throw const RemotePeerError(
type: RemotePeerErrorType.invalidSession,
message: 'No host address available for this session. Please scan a new QR code.',
);
}
await joinSession(session.sessionId, session.pin, session.hostAddress!);
}
/// Remove a recent session
Future<void> removeRecentSession(String sessionId) async {
if (_discoveryService != null) {
await _discoveryService!.removeRecentSession(sessionId);
}
}
/// Clear all recent sessions
Future<void> clearRecentSessions() async {
if (_discoveryService != null) {
await _discoveryService!.clearRecentSessions();
}
}
@override
void dispose() {
_reconnectTimer?.cancel();
leaveSession();
_recentSessionsSubscription?.cancel();
_discoveryService?.dispose();
super.dispose();
}
}
+10 -11
View File
@@ -656,19 +656,18 @@ class DownloadProvider extends ChangeNotifier {
}
}
// Fetch full metadata to get year, summary, clearLogo
// The metadata from getChildren() is summarized and missing these fields.
// If metadata already has summary, it's already full (e.g., from detail screen).
// Always fetch full metadata before downloading.
// Hub items may have summary but the cache at /library/metadata/$ratingKey
// won't have the full API response (with Media/Part data needed for video URL)
// unless getMetadataWithImages has been called.
PlexMetadata metadataToStore = metadata;
if (metadata.summary == null) {
try {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
try {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
}
// For episodes, also fetch and store show and season metadata for offline display
+5 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/plex_client.dart';
@@ -20,6 +22,7 @@ class LiveTvServerInfo {
class MultiServerProvider extends ChangeNotifier {
final MultiServerManager _serverManager;
final DataAggregationService _aggregationService;
StreamSubscription? _statusSubscription;
/// Whether any connected server has Live TV / DVR
bool _hasLiveTv = false;
@@ -31,7 +34,7 @@ class MultiServerProvider extends ChangeNotifier {
MultiServerProvider(this._serverManager, this._aggregationService) {
// Listen to server status changes
_serverManager.statusStream.listen((_) {
_statusSubscription = _serverManager.statusStream.listen((_) {
notifyListeners();
// Re-check live TV availability when servers come online
checkLiveTvAvailability();
@@ -145,6 +148,7 @@ class MultiServerProvider extends ChangeNotifier {
@override
void dispose() {
_statusSubscription?.cancel();
_serverManager.dispose();
super.dispose();
}
+3 -1
View File
@@ -28,7 +28,9 @@ class OfflineModeProvider extends ChangeNotifier {
/// Updates network and server connection flags
Future<void> _updateConnectionFlags() async {
final connectivityResult = await Connectivity().checkConnectivity();
final connectivityResult = await Connectivity()
.checkConnectivity()
.timeout(const Duration(seconds: 3), onTimeout: () => [ConnectivityResult.other]);
_hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none);
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
}
+10
View File
@@ -12,6 +12,7 @@ class SettingsProvider extends ChangeNotifier {
bool _showServerNameOnHubs = false;
bool _alwaysKeepSidebarOpen = false;
bool _showUnwatchedCount = true;
bool _hideSpoilers = false;
bool _isInitialized = false;
Future<void>? _initFuture;
@@ -36,6 +37,7 @@ class SettingsProvider extends ChangeNotifier {
_showServerNameOnHubs = _settingsService!.getShowServerNameOnHubs();
_alwaysKeepSidebarOpen = _settingsService!.getAlwaysKeepSidebarOpen();
_showUnwatchedCount = _settingsService!.getShowUnwatchedCount();
_hideSpoilers = _settingsService!.getHideSpoilers();
_isInitialized = true;
notifyListeners();
}
@@ -59,6 +61,8 @@ class SettingsProvider extends ChangeNotifier {
bool get showUnwatchedCount => _showUnwatchedCount;
bool get hideSpoilers => _hideSpoilers;
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
Future<void> _updateSetting<T>({
required T current,
@@ -122,6 +126,12 @@ class SettingsProvider extends ChangeNotifier {
persist: _settingsService!.setShowUnwatchedCount,
);
Future<void> setHideSpoilers(bool value) => _updateSetting(
current: _hideSpoilers, value: value,
setLocal: (v) => _hideSpoilers = v,
persist: _settingsService!.setHideSpoilers,
);
String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
+17
View File
@@ -1,4 +1,6 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../services/settings_service.dart' as settings;
import '../theme/mono_theme.dart';
@@ -24,6 +26,7 @@ class ThemeProvider extends ChangeNotifier {
Future<void> _initializeSettings() async {
_settingsService = await settings.SettingsService.getInstance();
_themeMode = _settingsService.getThemeMode();
_updateSplashTheme(_themeMode);
notifyListeners();
}
@@ -63,14 +66,28 @@ class ThemeProvider extends ChangeNotifier {
}
}
static const _themeChannel = MethodChannel('app.plezy/theme');
Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode != mode) {
_themeMode = mode;
await _settingsService.setThemeMode(mode);
_updateSplashTheme(mode);
notifyListeners();
}
}
void _updateSplashTheme(settings.ThemeMode mode) {
if (!Platform.isAndroid) return;
final name = switch (mode) {
settings.ThemeMode.dark => 'dark',
settings.ThemeMode.oled => 'oled',
settings.ThemeMode.light => 'light',
settings.ThemeMode.system => 'system',
};
_themeChannel.invokeMethod('setSplashTheme', {'mode': name});
}
String get themeModeDisplayName {
switch (_themeMode) {
case settings.ThemeMode.light:
+4 -5
View File
@@ -17,6 +17,7 @@ import '../theme/mono_tokens.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../utils/navigation_transitions.dart';
import 'main_screen.dart';
class AuthScreen extends StatefulWidget {
@@ -96,6 +97,7 @@ class _AuthScreenState extends State<AuthScreen> {
multiServerProvider: context.read<MultiServerProvider>(),
librariesProvider: context.read<LibrariesProvider>(),
syncService: context.read<OfflineWatchSyncService>(),
clientIdentifier: _authService.clientIdentifier,
);
if (!result.hasConnections) {
@@ -112,10 +114,7 @@ class _AuthScreenState extends State<AuthScreen> {
await profileFuture;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)),
);
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
setState(() {
@@ -454,7 +453,7 @@ class _AuthScreenState extends State<AuthScreen> {
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
),
child: Text(t.auth.debugEnterToken, style: TextStyle(fontSize: 12)),
child: Text(t.auth.debugEnterToken, style: const TextStyle(fontSize: 12)),
),
],
if (_errorMessage != null) ...[
+9 -20
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_action_bar.dart';
import '../models/plex_metadata.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
@@ -38,9 +39,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
@override
bool get hasItems => items.isNotEmpty;
@override
int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty)
@override
void dispose() {
disposeFocusResources();
@@ -69,23 +67,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
}
@override
List<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
if (items.isNotEmpty) {
buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems));
buttons.add(
AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
);
}
buttons.add(
AppBarButtonConfig(
icon: Symbols.delete_rounded,
tooltip: t.common.delete,
onPressed: _deleteCollection,
color: Colors.red,
),
);
return buttons;
List<FocusableAction> getAppBarActions() {
return [
if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
],
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red),
];
}
Future<void> _deleteCollection() async {
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../i18n/strings.g.dart';
import '../../providers/companion_remote_provider.dart';
@@ -201,7 +201,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle),
decoration: const BoxDecoration(color: Colors.green, shape: BoxShape.circle),
),
],
),
@@ -648,7 +648,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> {
hintText: t.companionRemote.remote.searchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
border: OutlineInputBorder(borderRadius: const BorderRadius.all(Radius.circular(100))),
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(100))),
),
onSubmitted: _submit,
),
+20 -219
View File
@@ -5,11 +5,8 @@ import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../providers/companion_remote_provider.dart';
import '../../utils/formatters.dart';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../utils/app_logger.dart';
class PairingScreen extends StatefulWidget {
@@ -25,8 +22,6 @@ class _PairingScreenState extends State<PairingScreen> {
final _pinController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isConnecting = false;
String? _connectingSessionId;
bool _isDiscovering = false;
String? _errorMessage;
int _selectedTab = 0;
@@ -36,15 +31,9 @@ class _PairingScreenState extends State<PairingScreen> {
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
// Tab indices shift when scan tab is present
int get _scanTabIndex => _isMobile ? 1 : -1;
int get _manualTabIndex => _isMobile ? 2 : 1;
@override
void initState() {
super.initState();
_loadRecentSessions();
}
// Tab indices: mobile gets Scan (0) + Manual (1), desktop gets Manual (0)
int get _scanTabIndex => _isMobile ? 0 : -1;
int get _manualTabIndex => _isMobile ? 1 : 0;
@override
void dispose() {
@@ -55,51 +44,6 @@ class _PairingScreenState extends State<PairingScreen> {
super.dispose();
}
Future<void> _loadRecentSessions() async {
setState(() {
_isDiscovering = true;
_errorMessage = null;
});
try {
await context.read<CompanionRemoteProvider>().loadRecentSessions();
if (!mounted) return;
setState(() {
_isDiscovering = false;
});
} catch (e) {
appLogger.e('Failed to load recent sessions', error: e);
if (!mounted) return;
setState(() {
_isDiscovering = false;
_errorMessage = t.companionRemote.pairing.failedToLoadRecent(error: e.toString());
});
}
}
Future<void> _connectToRecentSession(RecentRemoteSession session) async {
setState(() {
_isConnecting = true;
_connectingSessionId = session.sessionId;
_errorMessage = null;
});
try {
await context.read<CompanionRemoteProvider>().connectToRecentSession(session);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
appLogger.e('Failed to connect to recent session', error: e);
setState(() {
_isConnecting = false;
_connectingSessionId = null;
_errorMessage = _parseErrorMessage(e.toString());
});
}
}
Future<void> _connect() async {
if (!_formKey.currentState!.validate()) {
return;
@@ -209,39 +153,30 @@ class _PairingScreenState extends State<PairingScreen> {
return Scaffold(
appBar: AppBar(
title: Text(t.companionRemote.connectToDevice),
actions: [
if (_selectedTab == 0)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _isDiscovering ? null : _loadRecentSessions,
tooltip: t.common.refresh,
),
],
),
body: Column(
children: [
SegmentedButton<int>(
segments: [
ButtonSegment(value: 0, label: Text(t.companionRemote.pairing.recent), icon: const Icon(Icons.history)),
if (_isMobile)
if (_isMobile)
SegmentedButton<int>(
segments: [
ButtonSegment(
value: _scanTabIndex,
label: Text(t.companionRemote.pairing.scan),
icon: const Icon(Icons.qr_code_scanner),
),
ButtonSegment(
value: _manualTabIndex,
label: Text(t.companionRemote.pairing.manual),
icon: const Icon(Icons.keyboard),
),
],
selected: {_selectedTab},
onSelectionChanged: (Set<int> selection) {
setState(() {
_selectedTab = selection.first;
});
},
),
ButtonSegment(
value: _manualTabIndex,
label: Text(t.companionRemote.pairing.manual),
icon: const Icon(Icons.keyboard),
),
],
selected: {_selectedTab},
onSelectionChanged: (Set<int> selection) {
setState(() {
_selectedTab = selection.first;
});
},
),
Expanded(child: _buildTabContent()),
],
),
@@ -249,8 +184,7 @@ class _PairingScreenState extends State<PairingScreen> {
}
Widget _buildTabContent() {
if (_selectedTab == 0) return _buildDiscoveryTab();
if (_selectedTab == _scanTabIndex) return _buildScanTab();
if (_selectedTab == _scanTabIndex && _isMobile) return _buildScanTab();
return _buildManualEntryTab();
}
@@ -337,139 +271,6 @@ class _PairingScreenState extends State<PairingScreen> {
);
}
Widget _buildDiscoveryTab() {
return Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
final sessions = provider.recentSessions;
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.history, size: 64, color: Colors.blue),
const SizedBox(height: 24),
Text(
t.companionRemote.pairing.recentConnections,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
t.companionRemote.pairing.quickReconnect,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
if (_isDiscovering) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(t.common.loading, style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center),
] else if (sessions.isEmpty) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Icon(Icons.devices_other, size: 48, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(
t.companionRemote.pairing.noRecentConnections,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
t.companionRemote.pairing.connectUsingManual,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
...sessions.map((session) {
final isThisConnecting = _isConnecting && _connectingSessionId == session.sessionId;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(Icons.computer, size: 40),
title: Text(session.deviceName),
subtitle: Text(
'${session.platform}\n'
'Session: ${session.sessionId}\n'
'Last used: ${_formatDate(session.lastConnected)}',
),
isThreeLine: true,
trailing: isThisConnecting
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.arrow_forward),
onTap: _isConnecting ? null : () => _connectToRecentSession(session),
onLongPress: () => _showRemoveSessionDialog(session),
),
);
}),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
),
),
),
],
],
),
);
},
);
}
String _formatDate(DateTime date) {
return formatRelativeTime(date);
}
Future<void> _showRemoveSessionDialog(RecentRemoteSession session) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.companionRemote.pairing.removeRecentConnection),
content: Text(t.companionRemote.pairing.removeConfirm(name: session.deviceName)),
actions: [
FocusableButton(
autofocus: true,
onPressed: () => Navigator.pop(context, false),
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
),
FocusableButton(
onPressed: () => Navigator.pop(context, true),
child: TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.remove)),
),
],
),
);
if (confirmed == true && mounted) {
await context.read<CompanionRemoteProvider>().removeRecentSession(session.sessionId);
}
}
Widget _buildManualEntryTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
+155 -260
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/key_event_utils.dart';
import '../utils/global_key_utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -131,14 +132,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Hero and app bar focus
late FocusNode _heroFocusNode;
late FocusNode _refreshButtonFocusNode;
late FocusNode _watchTogetherButtonFocusNode;
late FocusNode _companionRemoteButtonFocusNode;
late FocusNode _userButtonFocusNode;
bool _isRefreshFocused = false;
bool _isWatchTogetherFocused = false;
bool _isCompanionRemoteFocused = false;
bool _isUserFocused = false;
final _actionBarKey = GlobalKey<FocusableActionBarState>();
/// Get the correct PlexClient for an item's server
PlexClient _getClientForItem(PlexMetadata? item) {
@@ -188,7 +182,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isHeroSectionVisible) {
_heroFocusNode.requestFocus();
} else {
_refreshButtonFocusNode.requestFocus();
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
}
_scrollToTop();
}
@@ -245,14 +239,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
_companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button');
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
_companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange);
_userButtonFocusNode.addListener(_onUserFocusChange);
_loadContent();
_startAutoScroll();
}
@@ -272,45 +258,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_loadContent();
}
void _onRefreshFocusChange() {
if (mounted) {
setState(() {
_isRefreshFocused = _refreshButtonFocusNode.hasFocus;
});
}
}
void _onWatchTogetherFocusChange() {
if (mounted) {
setState(() {
_isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus;
});
}
}
void _onCompanionRemoteFocusChange() {
if (mounted) {
setState(() {
_isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus;
});
}
}
void _onUserFocusChange() {
if (mounted) {
setState(() {
_isUserFocused = _userButtonFocusNode.hasFocus;
});
}
}
/// Handle key events for the hero section
late final _handleHeroKeyEvent = dpadKeyHandler(
onDown: () {
final keys = _allHubKeys;
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
},
onUp: () => _refreshButtonFocusNode.requestFocus(),
onUp: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
onLeft: () {
if (_currentHeroIndex > 0) {
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
@@ -330,45 +284,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
},
);
/// Handle key events for the refresh button in app bar
late final _handleRefreshKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onRight: () => _watchTogetherButtonFocusNode.requestFocus(),
onLeft: _navigateToSidebar,
onUp: () {}, // Block at boundary
onSelect: _loadContent,
);
/// Handle key events for the watch together button in app bar
late final _handleWatchTogetherKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _refreshButtonFocusNode.requestFocus(),
onRight: () => _companionRemoteButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
);
/// Handle key events for the companion remote button in app bar
late final _handleCompanionRemoteKeyEvent = dpadKeyHandler(
onDown: () => _heroFocusNode.requestFocus(),
onLeft: () => _watchTogetherButtonFocusNode.requestFocus(),
onRight: () => _userButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => RemoteSessionDialog.show(context),
);
/// Handle key events for the user button in app bar
late final _handleUserKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _companionRemoteButtonFocusNode.requestFocus(),
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onSelect: () {
final userProvider = context.read<UserProfileProvider>();
_showUserMenu(context, userProvider);
},
);
@override
void dispose() {
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
@@ -379,14 +294,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_heroController.dispose();
_scrollController.dispose();
_heroFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
_watchTogetherButtonFocusNode.dispose();
_companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange);
_companionRemoteButtonFocusNode.dispose();
_userButtonFocusNode.removeListener(_onUserFocusChange);
_userButtonFocusNode.dispose();
super.dispose();
}
@@ -849,7 +756,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Show user menu programmatically (for D-pad select)
void _showUserMenu(BuildContext context, UserProfileProvider userProvider) {
final RenderBox? button = _userButtonFocusNode.context?.findRenderObject() as RenderBox?;
final actionBar = _actionBarKey.currentState;
if (actionBar == null) return;
final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1);
final RenderBox? button = lastNode.context?.findRenderObject() as RenderBox?;
if (button == null) return;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
@@ -869,12 +779,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [AppIcon(Symbols.people_rounded, fill: 1), SizedBox(width: 8), Text(t.discover.switchProfile)],
children: [
AppIcon(Symbols.people_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.discover.switchProfile),
],
),
),
PopupMenuItem(
value: 'logout',
child: Row(children: [AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), Text(t.common.logout)]),
child: Row(
children: [AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)],
),
),
],
).then((value) {
@@ -916,171 +832,149 @@ class _DiscoverScreenState extends State<DiscoverScreen>
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
const Spacer(),
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white),
onPressed: _loadContent,
),
),
),
// Watch Together button
Consumer<WatchTogetherProvider>(
builder: (context, watchTogether, child) {
return Focus(
focusNode: _watchTogetherButtonFocusNode,
onKeyEvent: _handleWatchTogetherKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.group_rounded,
fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
builder: (context, watchTogether, companionRemote, _) {
final isDesktop = PlatformDetector.isDesktop(context);
final userProvider = context.watch<UserProfileProvider>();
return FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: _focusContentFromAppBar,
actions: [
FocusableAction(icon: Symbols.refresh_rounded, iconColor: Colors.white, onPressed: _loadContent),
// Watch Together
FocusableAction(
onPressed: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.group_rounded,
fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
),
tooltip: 'Watch Together',
),
onPressed: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
tooltip: 'Watch Together',
),
// Badge showing participant count when in session
if (watchTogether.isInSession && watchTogether.participantCount > 1)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Text(
'${watchTogether.participantCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 10,
fontWeight: FontWeight.bold,
if (watchTogether.isInSession && watchTogether.participantCount > 1)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Text(
'${watchTogether.participantCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
],
),
),
),
);
},
),
// Companion Remote button
Consumer<CompanionRemoteProvider>(
builder: (context, companionRemote, child) {
final isDesktop = PlatformDetector.isDesktop(context);
final hasDpadNav = isDesktop || PlatformDetector.isTV();
return Focus(
focusNode: hasDpadNav ? _companionRemoteButtonFocusNode : null,
onKeyEvent: hasDpadNav ? _handleCompanionRemoteKeyEvent : null,
child: Container(
decoration: BoxDecoration(
color: hasDpadNav && _isCompanionRemoteFocused
? Colors.white.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.phone_android_rounded,
fill: companionRemote.isConnected ? 1 : 0,
color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white,
),
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen()));
}
},
tooltip: t.companionRemote.title,
),
// Badge showing connection status
if (companionRemote.isConnected)
Positioned(
top: 6,
right: 6,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
),
),
),
],
),
),
);
},
),
Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
return Focus(
focusNode: _userButtonFocusNode,
onKeyEvent: _handleUserKeyEvent,
child: DecoratedBox(
decoration: BoxDecoration(
color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
// Companion Remote
FocusableAction(
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
);
}
},
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.phone_android_rounded,
fill: companionRemote.isConnected ? 1 : 0,
color: companionRemote.isConnected
? Theme.of(context).colorScheme.primary
: Colors.white,
),
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
);
}
},
tooltip: t.companionRemote.title,
),
if (companionRemote.isConnected)
Positioned(
top: 6,
right: 6,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
),
),
),
],
),
),
// User menu
FocusableAction(
onPressed: () => _showUserMenu(context, userProvider),
child: PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
}
},
itemBuilder: (context) => [
if (userProvider.hasMultipleUsers)
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
AppIcon(Symbols.people_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.discover.switchProfile),
],
),
),
PopupMenuItem(
value: 'switch_profile',
value: 'logout',
child: Row(
children: [
AppIcon(Symbols.people_rounded, fill: 1),
SizedBox(width: 8),
Text(t.discover.switchProfile),
AppIcon(Symbols.logout_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.common.logout),
],
),
),
PopupMenuItem(
value: 'logout',
child: Row(
children: [
AppIcon(Symbols.logout_rounded, fill: 1),
SizedBox(width: 8),
Text(t.common.logout),
],
),
),
],
],
),
),
),
],
);
},
),
@@ -1215,11 +1109,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
SizedBox(height: 16),
const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)),
],
),
),
@@ -1264,7 +1158,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
},
itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]);
return _buildHeroItem(_onDeck[index], heroHeight);
},
),
// Bottom gradient that extends past hero bounds to ensure seamless blend
@@ -1380,7 +1274,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
);
}
Widget _buildHeroItem(PlexMetadata heroItem) {
Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) {
final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.title;
final screenWidth = MediaQuery.of(context).size.width;
@@ -1406,7 +1300,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
clipBehavior: Clip.none,
children: [
// Background Image with fade/zoom animation and parallax
if (heroItem.art != null || heroItem.grandparentArt != null)
if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null)
ClipRect(
child: AnimatedBuilder(
animation: _scrollController,
@@ -1429,9 +1323,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final client = _getClientForItem(heroItem);
final mediaQuery = MediaQuery.of(context);
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
final containerAspect = screenWidth / heroHeight;
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: heroItem.art ?? heroItem.grandparentArt,
thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt,
maxWidth: mediaQuery.size.width,
maxHeight: mediaQuery.size.height * 0.7,
devicePixelRatio: dpr,
@@ -1692,7 +1587,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
] else
Text(
t.common.play,
style: TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
),
],
),
+14 -124
View File
@@ -1,26 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import '../providers/settings_provider.dart';
import '../utils/grid_size_calculator.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
/// Configuration for app bar buttons
class AppBarButtonConfig {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
final Color? color;
const AppBarButtonConfig({required this.icon, required this.tooltip, required this.onPressed, this.color});
}
/// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
///
@@ -29,36 +16,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// Scroll controller for scrolling to top when app bar is focused
final ScrollController scrollController = ScrollController();
// App bar focus nodes
final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play');
final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle');
final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete');
// Action bar key for accessing focus nodes
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
// App bar focus state
bool isAppBarFocused = false;
int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons)
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
bool backHandledByKeyEvent = false;
/// Number of app bar buttons (override if different from 3)
int get appBarButtonCount => 3;
/// Called when items are available and we want to check if focus should be set
bool get hasItems;
/// Called to get the list of app bar button configurations
List<AppBarButtonConfig> getAppBarButtons();
/// Called to get the list of app bar action configurations
List<FocusableAction> getAppBarActions();
/// Dispose focus-related resources. Call this from your dispose() method.
void disposeFocusResources() {
scrollController.dispose();
playButtonFocusNode.dispose();
shuffleButtonFocusNode.dispose();
deleteButtonFocusNode.dispose();
firstItemFocusNode.dispose();
disposeGridFocusNodes();
}
@@ -67,9 +45,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
void navigateToAppBar() {
setState(() {
isAppBarFocused = true;
appBarFocusedButton = 0;
});
_focusAppBarButton(0);
actionBarKey.currentState?.getFocusNode(0).requestFocus();
// Scroll to top to show the app bar
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
@@ -115,103 +92,16 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
}
}
/// Focus a specific app bar button by index
void _focusAppBarButton(int index) {
switch (index) {
case 0:
playButtonFocusNode.requestFocus();
break;
case 1:
shuffleButtonFocusNode.requestFocus();
break;
case 2:
deleteButtonFocusNode.requestFocus();
break;
}
}
/// Handle key events when app bar is focused
KeyEventResult handleAppBarKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final maxButton = appBarButtonCount - 1;
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
if (backResult != KeyEventResult.ignored) {
return backResult;
}
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key.isLeftKey && appBarFocusedButton > 0) {
setState(() => appBarFocusedButton--);
_focusAppBarButton(appBarFocusedButton);
return KeyEventResult.handled;
}
if (key.isRightKey && appBarFocusedButton < maxButton) {
setState(() => appBarFocusedButton++);
_focusAppBarButton(appBarFocusedButton);
return KeyEventResult.handled;
}
if (key.isDownKey) {
// Return focus to grid
navigateToGrid();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
final buttons = getAppBarButtons();
if (appBarFocusedButton < buttons.length) {
buttons[appBarFocusedButton].onPressed();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Build focusable app bar action widgets
List<Widget> buildFocusableAppBarActions() {
final colorScheme = Theme.of(context).colorScheme;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final buttons = getAppBarButtons();
return buttons.asMap().entries.map((entry) {
final index = entry.key;
final config = entry.value;
final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index;
FocusNode focusNode;
switch (index) {
case 0:
focusNode = playButtonFocusNode;
break;
case 1:
focusNode = shuffleButtonFocusNode;
break;
case 2:
focusNode = deleteButtonFocusNode;
break;
default:
focusNode = FocusNode();
}
return Focus(
focusNode: focusNode,
onKeyEvent: handleAppBarKeyEvent,
child: Container(
decoration: isFocused
? BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(20)),
)
: null,
child: IconButton(
icon: AppIcon(config.icon, fill: 1),
tooltip: config.tooltip,
onPressed: config.onPressed,
color: config.color,
),
),
);
}).toList();
return [
FocusableActionBar(
key: actionBarKey,
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
actions: getAppBarActions(),
),
];
}
/// Auto-focus first item after load if in keyboard mode.
+12 -47
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
@@ -15,11 +14,8 @@ import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/overlay_sheet.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import 'libraries/sort_bottom_sheet.dart';
import 'libraries/state_messages.dart';
@@ -48,7 +44,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
String? _errorMessage;
late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item');
late final FocusNode _sortButtonFocusNode = FocusNode(debugLabel: 'hub_detail_sort');
final _actionBarKey = GlobalKey<FocusableActionBarState>();
bool _isAppBarFocused = false;
bool _backHandledByKeyEvent = false;
@@ -63,7 +59,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
void initState() {
super.initState();
_sortButtonFocusNode.addListener(_onSortButtonFocusChange);
// Start with items already loaded in the hub
_items = widget.hub.items;
_filteredItems = widget.hub.items;
@@ -84,23 +79,11 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
void dispose() {
_sortButtonFocusNode.removeListener(_onSortButtonFocusChange);
_firstItemFocusNode.dispose();
_sortButtonFocusNode.dispose();
disposeGridFocusNodes();
super.dispose();
}
void _onSortButtonFocusChange() {
if (!mounted) return;
final hasFocus = _sortButtonFocusNode.hasFocus;
if (hasFocus && !_isAppBarFocused) {
setState(() => _isAppBarFocused = true);
} else if (!hasFocus && _isAppBarFocused) {
setState(() => _isAppBarFocused = false);
}
}
void _focusGrid() {
if (_filteredItems.isEmpty) return;
final targetIndex =
@@ -114,7 +97,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
void _navigateToAppBar() {
setState(() => _isAppBarFocused = true);
_sortButtonFocusNode.requestFocus();
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
}
void _handleBackFromContent() {
@@ -122,24 +105,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
_navigateToAppBar();
}
KeyEventResult _handleSortButtonKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
if (backResult != KeyEventResult.ignored) return backResult;
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key.isDownKey) {
_focusGrid();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_showSortBottomSheet();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Future<void> _loadSorts() async {
try {
@@ -315,7 +280,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
Widget build(BuildContext context) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final sortButtonFocused = isKeyboardMode && _isAppBarFocused;
return PopScope(
canPop: !isKeyboardMode || _isAppBarFocused,
@@ -336,16 +300,17 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
title: Text(widget.hub.title),
pinned: true,
actions: [
Focus(
focusNode: _sortButtonFocusNode,
onKeyEvent: _handleSortButtonKeyEvent,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: sortButtonFocused, borderRadius: 20),
child: IconButton(
icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort),
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusGrid,
onBack: () => Navigator.pop(context),
actions: [
FocusableAction(
icon: Symbols.swap_vert_rounded,
tooltip: t.libraries.sort,
onPressed: _showSortBottomSheet,
),
),
],
),
],
),
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_first_character.dart';
import '../../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Vertical strip of letters for jumping through sorted library items.
@@ -1,5 +1,5 @@
import '../data/ducet_order.dart';
import '../models/plex_first_character.dart';
import '../../data/ducet_order.dart';
import '../../models/plex_first_character.dart';
/// Shared letter-index mapping logic used by both [AlphaJumpBar] (desktop/tablet/TV)
/// and [AlphaScrollHandle] (phone).
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../models/plex_first_character.dart';
import '../../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Phone-optimized draggable scroll handle that appears on scroll and shows
+18 -85
View File
@@ -4,9 +4,10 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../mixins/tab_navigation_mixin.dart';
@@ -127,11 +128,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_playlistsTabChipFocusNode,
];
// App bar action button focus
late FocusNode _editButtonFocusNode;
late FocusNode _refreshButtonFocusNode;
bool _isEditFocused = false;
bool _isRefreshFocused = false;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController();
@@ -141,12 +139,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
super.initState();
initTabNavigation();
// Initialize action button focus nodes
_editButtonFocusNode = FocusNode(debugLabel: 'EditButton');
_refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
_editButtonFocusNode.addListener(_onEditFocusChange);
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
// Initialize with libraries from the provider
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeWithLibraries();
@@ -338,42 +330,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_focusCurrentTab();
}
void _onEditFocusChange() {
if (mounted) {
setState(() => _isEditFocused = _editButtonFocusNode.hasFocus);
}
}
void _onRefreshFocusChange() {
if (mounted) {
setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
}
/// Handle key events for the edit button in app bar
late final _handleEditKeyEvent = dpadKeyHandler(
onLeft: () => getTabChipFocusNode(3).requestFocus(),
onRight: () => _refreshButtonFocusNode.requestFocus(),
onDown: _focusCurrentTab,
onUp: () {}, // Block at boundary
onSelect: _showLibraryManagementSheet,
);
/// Handle key events for the refresh button in app bar
late final _handleRefreshKeyEvent = dpadKeyHandler(
onLeft: () {
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
getTabChipFocusNode(3).requestFocus();
}
},
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onDown: _focusCurrentTab,
onSelect: _refreshCurrentTab,
);
@override
void dispose() {
@@ -383,10 +339,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_browseTabChipFocusNode.dispose();
_collectionsTabChipFocusNode.dispose();
_playlistsTabChipFocusNode.dispose();
_editButtonFocusNode.removeListener(_onEditFocusChange);
_editButtonFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
@@ -524,7 +476,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
) async {
while (_hasMoreItems && requestId == _requestId) {
try {
final items = await client.getLibraryContent(
final result = await client.getLibraryContent(
library.key,
start: _currentPage * _pageSize,
size: _pageSize,
@@ -533,7 +485,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
// Tag items with server info for multi-server support
final taggedItems = items
final taggedItems = result.items
.map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName))
.toList();
@@ -935,13 +887,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
getTabChipFocusNode(newIndex).requestFocus();
}
: () {
// Navigate to first action button (edit if libraries exist, else refresh)
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
_refreshButtonFocusNode.requestFocus();
}
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
},
onNavigateDown: _focusCurrentTabFromTabBar,
onBack: onTabBarBack,
@@ -1048,36 +994,23 @@ class _LibrariesScreenState extends State<LibrariesScreen>
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
actions: [
if (allLibraries.isNotEmpty)
Focus(
focusNode: _editButtonFocusNode,
onKeyEvent: _handleEditKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.edit_rounded, fill: 1),
FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
if (allLibraries.isNotEmpty)
FocusableAction(
icon: Symbols.edit_rounded,
tooltip: t.libraries.manageLibraries,
onPressed: _showLibraryManagementSheet,
),
),
),
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _refreshCurrentTab,
),
),
],
),
],
),
@@ -76,7 +76,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Focus management
bool _hasLoadedData = false;
bool _hasFocused = false;
@protected
bool hasFocused = false;
// Getters for subclasses
List<T> get items => _items;
@@ -122,7 +123,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
// Reset focus state for new library
_hasFocused = false;
hasFocused = false;
_hasLoadedData = false;
// Immediately clear stale data before async load
_items = [];
@@ -164,8 +165,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (widget.suppressAutoFocus) return;
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) {
_hasFocused = true;
if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
focusFirstItem();
+303 -118
View File
@@ -14,10 +14,11 @@ import '../../../providers/settings_provider.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../widgets/alpha_jump_bar.dart';
import '../../../widgets/alpha_jump_helper.dart';
import '../../../widgets/alpha_scroll_handle.dart';
import '../alpha_jump_bar.dart';
import '../alpha_jump_helper.dart';
import '../alpha_scroll_handle.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_card.dart';
import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/overlay_sheet.dart';
@@ -68,14 +69,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
String? get deletionServerId => widget.library.serverId;
@override
Set<String>? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet();
Set<String>? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (items.isEmpty) return <String>{};
if (_loadedItems.isEmpty) return <String>{};
final keys = <String>{};
for (final item in items) {
for (final item in _loadedItems.values) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(_toGlobalKey(item.ratingKey, serverId: serverId));
@@ -85,30 +86,30 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
@override
void onDeletionEvent(DeletionEvent event) {
// If we have an item that matches the rating key exactly, then remove it from our list
final index = items.indexWhere((e) => e.ratingKey == event.ratingKey);
if (index != -1) {
// If we have an item that matches the rating key exactly, remove it and rebuild indices
final matchEntry = _loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull;
if (matchEntry != null) {
setState(() {
items.removeAt(index);
_removeLoadedItemAndShift(matchEntry.key);
});
return;
}
// If a child item was delete, then update our list to reflect that.
// If a child item was deleted, update our item to reflect that.
// If all children were deleted, remove our item.
// Otherwise, just update the counts.
for (final parentKey in event.parentChain) {
final parentIndex = items.indexWhere((e) => e.ratingKey == parentKey);
if (parentIndex != -1) {
final item = items[parentIndex];
final parentEntry = _loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull;
if (parentEntry != null) {
final item = parentEntry.value;
final newLeafCount = (item.leafCount ?? 1) - event.leafCount;
if (newLeafCount <= 0) {
setState(() {
items.removeAt(parentIndex);
_removeLoadedItemAndShift(parentEntry.key);
});
} else {
setState(() {
items[parentIndex] = item.copyWith(leafCount: newLeafCount);
_loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
});
}
return;
@@ -116,18 +117,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Remove an item at [index] and shift all higher indices down by 1
void _removeLoadedItemAndShift(int index) {
_loadedItems.remove(index);
// Rebuild map with shifted indices for items above the removed one
final shifted = <int, PlexMetadata>{};
for (final entry in _loadedItems.entries) {
if (entry.key < index) {
shifted[entry.key] = entry.value;
} else {
shifted[entry.key - 1] = entry.value;
}
}
_loadedItems.clear();
_loadedItems.addAll(shifted);
_totalSize = (_totalSize - 1).clamp(0, _totalSize);
}
@override
String get focusNodeDebugLabel => 'browse_first_item';
@override
int get itemCount => items.length;
int get itemCount => _totalSize;
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
setState(() {
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
items[index] = updatedMetadata;
for (final entry in _loadedItems.entries) {
if (entry.value.ratingKey == ratingKey) {
_loadedItems[entry.key] = updatedMetadata;
break;
}
}
});
}
@@ -162,11 +182,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Timer? _scrollActivityTimer;
// Pagination state
int _currentPage = 0;
bool _hasMoreItems = true;
int _totalSize = 0;
final Map<int, PlexMetadata> _loadedItems = {};
final Set<int> _loadingRanges = {};
CancelToken? _cancelToken;
int _requestId = 0;
static const int _pageSize = 500;
static const int _fetchSize = 200;
Timer? _scrollIdleTimer;
// Focus nodes for filter chips
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
@@ -186,6 +208,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void dispose() {
_cancelToken?.cancel();
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
@@ -196,6 +219,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
super.dispose();
}
// Override tryFocus to use _loadedItems instead of base class items list
@override
void tryFocus() {
if (widget.suppressAutoFocus) return;
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusFirstItem();
});
}
}
// Override loadData to use our custom _loadContent
@override
Future<List<PlexMetadata>> loadData() async {
@@ -240,11 +275,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (items.isNotEmpty) {
if (_loadedItems.isNotEmpty) {
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && items.isNotEmpty && !firstItemFocusNode.hasFocus) {
if (mounted && _loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -268,7 +303,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Cancel any pending request
_cancelToken?.cancel();
_cancelToken = CancelToken();
final currentRequestId = ++_requestId;
// Use a generation counter for the filter/sort loading phase
final generation = ++_requestId;
// Extract context dependencies before async gap - use server-specific client
final client = getClientForLibrary();
@@ -277,8 +313,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
isLoading = true;
errorMessage = null;
items = [];
_currentPage = 0;
_hasMoreItems = true;
_totalSize = 0;
_loadedItems.clear();
_loadingRanges.clear();
// Clear filter/sort state while loading to prevent showing stale options
_filters = [];
_sortOptions = [];
@@ -303,8 +340,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
// Check if request was cancelled
if (currentRequestId != _requestId) return;
// Check if request was superseded
if (generation != _requestId) return;
if (!mounted) return;
setState(() {
@@ -327,62 +364,64 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
});
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters()]);
} catch (e) {
_handleLoadError(e, currentRequestId);
if (!mounted) return;
setState(() {
errorMessage = _getErrorMessage(e);
isLoading = false;
});
}
}
Future<void> _loadItems({bool loadMore = false}) async {
if (loadMore && isLoading) return;
/// Build the filter params map for API calls
Map<String, String> _buildFilterParams() {
final filterParams = Map<String, String>.from(_selectedFilters);
if (!loadMore) {
_currentPage = 0;
_hasMoreItems = true;
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
}
if (!_hasMoreItems) return;
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
}
final currentRequestId = _requestId;
return filterParams;
}
Future<void> _loadItems() async {
final currentRequestId = ++_requestId;
_cancelToken?.cancel();
_cancelToken = CancelToken();
setState(() {
isLoading = true;
if (!loadMore) {
items = [];
// Increment content version when loading fresh content
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(items.length);
}
items = [];
_totalSize = 0;
_loadedItems.clear();
_loadingRanges.clear();
// Increment content version when loading fresh content
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(0);
});
try {
// Use server-specific client for this library
final client = getClientForLibrary();
// Build filter params
final filterParams = Map<String, String>.from(_selectedFilters);
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
}
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
}
final filterParams = _buildFilterParams();
// Items are automatically tagged with server info by PlexClient
final loadedItems = await client.getLibraryContent(
final result = await client.getLibraryContent(
widget.library.key,
start: _currentPage * _pageSize,
size: _pageSize,
start: 0,
size: _fetchSize,
filters: filterParams,
cancelToken: _cancelToken,
);
@@ -391,33 +430,81 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
if (!mounted) return;
setState(() {
if (loadMore) {
items.addAll(loadedItems);
} else {
items = loadedItems;
_totalSize = result.totalSize;
for (var i = 0; i < result.items.length; i++) {
_loadedItems[i] = result.items[i];
}
_hasMoreItems = loadedItems.length >= _pageSize;
_currentPage++;
isLoading = false;
});
// On initial load (not pagination), mark data as loaded and try to focus
if (!loadMore) {
hasLoadedData = true;
tryFocus();
hasLoadedData = true;
tryFocus();
// Notify parent
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
});
}
// Notify parent
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
});
}
} catch (e) {
_handleLoadError(e, currentRequestId);
}
}
/// Fetch a range of items from the API and store them in the sparse map.
/// After a successful fetch, re-checks for remaining gaps in the visible range.
Future<void> _fetchRange(int start, int size) async {
// Clamp to totalSize
if (start >= _totalSize) return;
final clampedSize = size.clamp(0, _totalSize - start);
if (clampedSize == 0) return;
// Deduplicate: track every index in-flight to prevent overlapping fetches
final indices = List.generate(clampedSize, (i) => start + i);
if (indices.every((i) => _loadingRanges.contains(i) || _loadedItems.containsKey(i))) return;
_loadingRanges.addAll(indices);
final currentRequestId = _requestId;
try {
final client = getClientForLibrary();
final filterParams = _buildFilterParams();
final result = await client.getLibraryContent(
widget.library.key,
start: start,
size: clampedSize,
filters: filterParams,
cancelToken: _cancelToken,
);
if (currentRequestId != _requestId || !mounted) return;
setState(() {
for (var i = 0; i < result.items.length; i++) {
_loadedItems[start + i] = result.items[i];
}
// Update totalSize in case it changed (e.g., items added/removed on server)
if (result.totalSize != _totalSize) {
_totalSize = result.totalSize;
}
});
// Re-check for remaining gaps in the visible range after this fetch
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && currentRequestId == _requestId) {
_loadVisibleRange();
}
});
} catch (e) {
// Silently ignore fetch errors for background range loads
// (the initial load handles errors with UI feedback)
if (e is DioException && e.type == DioExceptionType.cancel) return;
} finally {
_loadingRanges.removeAll(indices);
}
}
void _handleLoadError(dynamic error, int currentRequestId) {
if (currentRequestId != _requestId) return;
@@ -617,9 +704,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (items.isEmpty) return;
if (_totalSize == 0) return;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < items.length ? lastFocusedGridIndex! : 0;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!) ? lastFocusedGridIndex! : 0;
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
if (targetIndex == 0) {
@@ -634,10 +721,33 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// FocusNode detached), so we target the last-column item in the first
/// visible row — the grid cell closest to the alpha bar.
void _navigateToGridNearScroll() {
if (items.isEmpty || _currentColumnCount < 1) return;
if (_totalSize == 0 || _currentColumnCount < 1) return;
final row = _currentFirstVisibleIndex ~/ _currentColumnCount;
final targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, items.length - 1);
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
// Find nearest loaded item — skeleton cards have no FocusNode
if (!_loadedItems.containsKey(targetIndex)) {
// Search backwards first (items above are more likely visible)
int? found;
for (var i = targetIndex - 1; i >= 0; i--) {
if (_loadedItems.containsKey(i)) {
found = i;
break;
}
}
// Then search forwards
if (found == null) {
for (var i = targetIndex + 1; i < _totalSize; i++) {
if (_loadedItems.containsKey(i)) {
found = i;
break;
}
}
}
if (found == null) return;
targetIndex = found;
}
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
@@ -676,6 +786,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
bool get _shouldShowAlphaJumpBar {
if (_selectedGrouping == 'folders') return false;
if (_firstCharacters.isEmpty) return false;
if (_firstCharacters.length < 6 || _alphaHelper.totalItemCount < 80) return false;
// Show when no sort is selected (default is titleSort) or when explicitly sorting by title
final sortKey = _selectedSort?.key ?? '';
return sortKey.isEmpty || sortKey.startsWith('titleSort');
@@ -710,11 +821,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Track scroll position to highlight the current letter in the jump bar
/// Track scroll position and trigger debounced range loading.
void _onScrollChanged() {
// Debounced scroll-idle handler: load visible range when scrolling settles
_scrollIdleTimer?.cancel();
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) _loadVisibleRange();
});
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
// During a jump animation, skip all processing to avoid flashing.
// During a jump animation, skip alpha bar processing to avoid flashing.
if (_isJumpScrolling) return;
// If pinned from a completed jump, the next scroll event must be
@@ -733,7 +850,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final firstInRow = _itemIndexFromScrollOffset(offset);
// Use the last item in the first visible row so the highlighted letter
// updates as soon as items with a new letter appear in that row.
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, items.length - 1);
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex);
if (lastInRow != _currentFirstVisibleIndex) {
setState(() => _currentFirstVisibleIndex = lastInRow);
}
@@ -754,7 +872,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
final row = (contentOffset / rowHeight).floor();
return (row * _currentColumnCount).clamp(0, items.length - 1);
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
return (row * _currentColumnCount).clamp(0, maxIndex);
}
/// Scroll to the item at [targetIndex], loading more pages if necessary.
@@ -766,13 +885,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_isJumpScrolling = true;
_hasJumpPin = true;
setState(() => _currentFirstVisibleIndex = targetIndex);
final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0);
setState(() => _currentFirstVisibleIndex = clamped);
if (targetIndex < items.length) {
_scrollToItemIndex(targetIndex);
} else {
_loadUntilIndex(targetIndex);
}
_scrollToItemIndex(clamped);
}
/// Scroll the grid so that [index] is visible just below the chips bar
@@ -809,16 +925,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
});
}
/// Load pages until [targetIndex] is loaded, then scroll to it
Future<void> _loadUntilIndex(int targetIndex) async {
while (items.length <= targetIndex && _hasMoreItems) {
await _loadItems(loadMore: true);
}
if (mounted) {
_scrollToItemIndex(targetIndex.clamp(0, items.length - 1));
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -876,13 +982,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
/// Builds the scrollable content (grid/list) with pagination support
/// Builds the scrollable content (grid/list) with scroll-idle loading
Widget _buildScrollableContent() {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) {
_loadItems(loadMore: true);
}
// Track scroll activity for phone scroll handle
if (notification is ScrollStartNotification) {
if (!_isScrollActive) setState(() => _isScrollActive = true);
@@ -904,6 +1007,48 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
/// Determine the visible range and fetch any unloaded items within it.
/// Covers the full visible area plus a buffer of _fetchSize/2 on each side,
/// then finds the first unloaded contiguous block and fetches it.
void _loadVisibleRange() {
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
if (_lastCrossAxisExtent <= 0) return;
final offset = _scrollController.offset;
final viewportHeight = _scrollController.position.viewportDimension;
final firstIndex = _itemIndexFromScrollOffset(offset);
// Calculate how many items fit in the viewport
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
final visibleCount = visibleRows * _currentColumnCount;
// Expand the visible range by a buffer on each side
final buffer = _fetchSize ~/ 2;
final rangeStart = (firstIndex - buffer).clamp(0, _totalSize);
final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, _totalSize);
// Find the first and last unloaded indices in the range
int? fetchStart;
int? fetchEnd;
for (var i = rangeStart; i < rangeEnd; i++) {
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
fetchStart ??= i;
fetchEnd = i + 1;
}
}
if (fetchStart == null || fetchEnd == null) return;
final fetchSize = fetchEnd - fetchStart;
if (fetchSize <= 0) return;
_fetchRange(fetchStart, fetchSize);
}
/// Whether the filters chip is visible
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
@@ -976,11 +1121,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds content as slivers for the CustomScrollView
List<Widget> _buildContentSlivers() {
if (isLoading && items.isEmpty) {
if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
}
if (errorMessage != null && items.isEmpty) {
if (errorMessage != null && _loadedItems.isEmpty) {
return [
SliverFillRemaining(
child: ErrorStateWidget(
@@ -993,7 +1138,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
];
}
if (items.isEmpty) {
if (_totalSize == 0 && !isLoading) {
return [
SliverFillRemaining(
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
@@ -1021,7 +1166,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
final itemCount = _totalSize;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
_effectiveTopPadding = topPadding;
@@ -1080,13 +1225,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
required bool isFirstColumn,
bool isLastColumn = false,
}) {
if (index >= items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
final item = _loadedItems[index];
// Show skeleton placeholder for unloaded items
if (item == null) {
return const _SkeletonCard();
}
final item = items[index];
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
// All other items get managed focus nodes for restoration
@@ -1106,3 +1250,44 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
}
/// Skeleton placeholder card that matches the poster + title layout of a real media card.
/// Not focusable — dpad focus skips over these.
class _SkeletonCard extends StatelessWidget {
const _SkeletonCard();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster area — matches the Expanded poster in _buildGridCard
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: const SkeletonLoader(child: SizedBox.expand()),
),
),
const SizedBox(height: 4),
// Title bar
SkeletonLoader(
borderRadius: BorderRadius.circular(4),
child: const SizedBox(height: 13, width: double.infinity),
),
const SizedBox(height: 3),
// Subtitle bar
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.6,
child: SkeletonLoader(
borderRadius: BorderRadius.circular(4),
child: const SizedBox(height: 11),
),
),
],
),
);
}
}
+12 -50
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_action_bar.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_dvr.dart';
@@ -31,9 +31,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final _guideTabKey = GlobalKey<GuideTabState>();
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
// App bar action button focus
final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
bool _isRefreshFocused = false;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
List<LiveTvChannel> _channels = [];
bool _isLoading = true;
@@ -47,7 +46,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.initState();
suppressAutoFocus = true;
initTabNavigation();
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_loadChannels();
}
@@ -55,15 +53,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void dispose() {
_guideTabFocusNode.dispose();
_whatsOnTabFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
void _onRefreshFocusChange() {
if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
@override
void onTabChanged() {
@@ -208,34 +201,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
@override
void focusActiveTabIfReady() => _focusCurrentTab();
// ---------------------------------------------------------------------------
// Action button key handlers
// ---------------------------------------------------------------------------
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
getTabChipFocusNode(tabCount - 1).requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey) {
return KeyEventResult.handled;
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_loadChannels();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
// ---------------------------------------------------------------------------
// Tab chips
@@ -276,7 +241,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
});
getTabChipFocusNode(newIndex).requestFocus();
}
: () => _refreshButtonFocusNode.requestFocus(),
: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack,
);
@@ -303,20 +268,17 @@ class _LiveTvScreenState extends State<LiveTvScreen>
)
: Text(t.liveTv.title),
actions: [
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded),
FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.liveTv.reloadGuide,
onPressed: _loadChannels,
),
),
],
),
],
),
+1 -1
View File
@@ -533,7 +533,7 @@ class GuideTabState extends State<GuideTab> {
children: [
Row(
children: [
SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
Expanded(
child: SingleChildScrollView(
controller: _headerHorizontalController,
+20 -7
View File
@@ -65,6 +65,7 @@ class MediaDetailScreen extends StatefulWidget {
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware, DeletionAware {
List<PlexMetadata> _seasons = [];
bool _isLoadingSeasons = false;
Completer<void>? _seasonsCompleter;
PlexMetadata? _fullMetadata;
PlexMetadata? _onDeckEpisode;
PlexVideoPlaybackData? _playbackData;
@@ -713,11 +714,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
const SizedBox(width: 12),
IconButton.filledTonal(
onPressed: () async {
final result = await Navigator.push(
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: metadata)),
);
if (result == true && mounted) {
if (mounted) {
_loadFullMetadata();
}
},
@@ -1025,6 +1026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
}
Future<void> _loadSeasons() async {
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
});
@@ -1048,11 +1050,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
setState(() {
_isLoadingSeasons = false;
});
} finally {
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
}
/// Load seasons from downloaded episodes (offline mode)
void _loadSeasonsFromDownloads() {
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
});
@@ -1087,6 +1094,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
_seasons = seasons;
_isLoadingSeasons = false;
});
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
/// Load extras (trailers, behind-the-scenes, etc.)
@@ -1654,8 +1664,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
}
// Wait for seasons to finish loading if they're currently loading
while (_isLoadingSeasons) {
await Future.delayed(const Duration(milliseconds: 100));
if (_isLoadingSeasons && _seasonsCompleter != null) {
await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {});
}
if (!mounted) return;
@@ -1853,14 +1863,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
SizedBox(
height: headerHeight,
width: double.infinity,
child: metadata.art != null
child: (metadata.art != null || metadata.backgroundSquare != null)
? Builder(
builder: (context) {
final containerAspect = size.width / headerHeight;
final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect);
// Check for offline local file first
if (widget.isOffline && widget.metadata.serverId != null) {
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
widget.metadata.serverId!,
metadata.art,
heroArtPath,
);
if (localPath != null && File(localPath).existsSync()) {
return Image.file(
@@ -1879,7 +1892,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: metadata.art,
thumbPath: heroArtPath,
maxWidth: mediaQuery.size.width,
maxHeight: mediaQuery.size.height * 0.6,
devicePixelRatio: dpr,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_action_bar.dart';
import '../../services/plex_client.dart';
import '../../services/play_queue_launcher.dart';
import '../../models/plex_playlist.dart';
@@ -51,33 +52,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
bool get hasItems => items.isNotEmpty;
@override
int get appBarButtonCount {
int count = 0;
if (items.isNotEmpty) count += 2; // play + shuffle
if (!widget.playlist.smart) count += 1; // delete
return count;
}
@override
List<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
if (items.isNotEmpty) {
buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems));
buttons.add(
AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
);
}
if (!widget.playlist.smart) {
buttons.add(
AppBarButtonConfig(
icon: Symbols.delete_rounded,
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
color: Colors.red,
),
);
}
return buttons;
List<FocusableAction> getAppBarActions() {
return [
if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
],
if (!widget.playlist.smart)
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.playlists.delete, onPressed: _deletePlaylist, iconColor: Colors.red),
];
}
// Focus management for regular (non-smart) reorderable lists
+4 -4
View File
@@ -365,7 +365,7 @@ class _TvPinInputState extends State<_TvPinInput> {
_digits[index] = digit;
_activeIndex = index;
_mobileControllers[index].text = digit.toString();
_mobileControllers[index].selection = TextSelection.collapsed(offset: 1);
_mobileControllers[index].selection = const TextSelection.collapsed(offset: 1);
});
if (index < 3) {
@@ -436,12 +436,12 @@ class _TvPinInputState extends State<_TvPinInput> {
maxLength: 2, // allow overwrite
obscureText: true,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
decoration: InputDecoration(
decoration: const InputDecoration(
counterText: '',
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
borderRadius: BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _onMobileDigitChanged(i, value),
+6 -6
View File
@@ -235,16 +235,16 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
: null,
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+27 -6
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -14,6 +15,8 @@ import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/content_utils.dart';
import '../services/download_storage_service.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/plex_optimized_image.dart';
@@ -58,7 +61,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
bool _suppressNextBackKeyUp = false;
bool _routeSubscribed = false;
String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey);
String _toGlobalKey(String ratingKey, {String? serverId}) =>
buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey);
// WatchStateAware: watch all episode ratingKeys
@override
@@ -363,14 +367,18 @@ class _EpisodeCardState extends State<_EpisodeCard> {
);
return Row(
children: [
if (widget.episode.duration != null) Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.duration != null)
Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
],
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
dot,
Padding(padding: const EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber)),
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber),
),
const SizedBox(width: 2),
Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
@@ -385,6 +393,9 @@ class _EpisodeCardState extends State<_EpisodeCard> {
@override
Widget build(BuildContext context) {
final hideSpoilers = context.watch<SettingsProvider>().hideSpoilers;
final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler;
// Hide progress when offline (not tracked)
final hasProgress =
!widget.isOffline &&
@@ -432,7 +443,17 @@ class _EpisodeCardState extends State<_EpisodeCard> {
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: AspectRatio(aspectRatio: 16 / 9, child: _buildEpisodeThumbnail()),
child: AspectRatio(
aspectRatio: 16 / 9,
child: shouldBlur
? ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: _buildEpisodeThumbnail(),
),
)
: _buildEpisodeThumbnail(),
),
),
// Play overlay
@@ -636,8 +657,8 @@ class _EpisodeCardState extends State<_EpisodeCard> {
},
),
// Summary
if (widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
// Summary (hidden when spoiler protection is active)
if (!shouldBlur && widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
if (PlatformDetector.isTV())
Text(
+115 -163
View File
@@ -2,15 +2,16 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/desktop_app_bar.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@@ -21,6 +22,7 @@ class LogsScreen extends StatefulWidget {
class _LogsScreenState extends State<LogsScreen> {
List<LogEntry> _logs = [];
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -28,6 +30,12 @@ class _LogsScreenState extends State<LogsScreen> {
_logs = MemoryLogOutput.getLogs();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _loadLogs() {
setState(() {
_logs = MemoryLogOutput.getLogs();
@@ -115,7 +123,7 @@ class _LogsScreenState extends State<LogsScreen> {
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: id));
showSuccessSnackBar(ctx, t.messages.logsCopied);
showSuccessSnackBar(context, t.messages.logsCopied);
},
),
],
@@ -156,178 +164,122 @@ class _LogsScreenState extends State<LogsScreen> {
}
}
IconData _getLevelIcon(Level level) {
switch (level) {
case Level.error:
case Level.fatal:
return Symbols.error_rounded;
case Level.warning:
return Symbols.warning_rounded;
case Level.info:
return Symbols.info_rounded;
case Level.debug:
case Level.trace:
return Symbols.bug_report_rounded;
default:
return Symbols.circle_rounded;
}
}
@override
Widget build(BuildContext context) {
return FocusedScrollScaffold(
title: Text(t.screens.logs),
actions: [
IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
onPressed: _loadLogs,
tooltip: t.common.refresh,
),
IconButton(
icon: const AppIcon(Symbols.upload_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
tooltip: t.logs.uploadLogs,
),
IconButton(
icon: const AppIcon(Symbols.content_copy_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
tooltip: t.logs.copyLogs,
),
IconButton(
icon: const AppIcon(Symbols.delete_outline_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _clearLogs : null,
tooltip: t.logs.clearLogs,
),
],
slivers: [
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final log = _logs[index];
return _LogEntryCard(
log: log,
formatTime: _formatTime,
levelColor: _getLevelColor(log.level),
levelIcon: _getLevelIcon(log.level),
);
}, childCount: _logs.length),
),
),
],
void _scroll(double delta) {
final pos = _scrollController.position;
_scrollController.animateTo(
(pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent),
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
class _LogEntryCard extends StatefulWidget {
final LogEntry log;
final String Function(DateTime) formatTime;
final Color levelColor;
final IconData levelIcon;
const _LogEntryCard({required this.log, required this.formatTime, required this.levelColor, required this.levelIcon});
@override
State<_LogEntryCard> createState() => _LogEntryCardState();
}
class _LogEntryCardState extends State<_LogEntryCard> {
bool _isExpanded = false;
List<TextSpan> _buildLogSpans() {
final spans = <TextSpan>[];
for (var i = 0; i < _logs.length; i++) {
if (i > 0) spans.add(const TextSpan(text: '\n'));
final log = _logs[i];
final color = _getLevelColor(log.level);
spans.add(TextSpan(
text: '[${_formatTime(log.timestamp)}] ',
style: TextStyle(color: color.withValues(alpha: 0.6)),
));
spans.add(TextSpan(
text: '[${log.level.name.toUpperCase()}] ',
style: TextStyle(color: color, fontWeight: FontWeight.bold),
));
spans.add(TextSpan(text: log.message));
if (log.error != null) {
spans.add(TextSpan(
text: '\n Error: ${log.error}',
style: TextStyle(color: color),
));
}
if (log.stackTrace != null) {
spans.add(TextSpan(
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
));
}
}
return spans;
}
@override
Widget build(BuildContext context) {
final hasErrorOrStackTrace = widget.log.error != null || widget.log.stackTrace != null;
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: InkWell(
onTap: hasErrorOrStackTrace ? () => setState(() => _isExpanded = !_isExpanded) : null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIcon(widget.levelIcon, fill: 1, color: widget.levelColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.log.level.name.toUpperCase(),
style: TextStyle(fontWeight: FontWeight.bold, color: widget.levelColor, fontSize: 12),
),
const SizedBox(width: 8),
Text(
widget.formatTime(widget.log.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(widget.log.message, style: Theme.of(context).textTheme.bodyMedium),
],
return Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyNavigation(context, event);
if (backResult != KeyEventResult.ignored) return backResult;
if (event is KeyDownEvent || event is KeyRepeatEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_scroll(80);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_scroll(-80);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
CustomAppBar(
title: Text(t.screens.logs),
pinned: true,
actions: [
FocusableActionBar(
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _loadLogs,
),
FocusableAction(
icon: Symbols.upload_rounded,
tooltip: t.logs.uploadLogs,
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
),
FocusableAction(
icon: Symbols.content_copy_rounded,
tooltip: t.logs.copyLogs,
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
),
FocusableAction(
icon: Symbols.delete_outline_rounded,
tooltip: t.logs.clearLogs,
onPressed: _logs.isNotEmpty ? _clearLogs : null,
),
],
),
],
),
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: SelectableText.rich(
TextSpan(
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
height: 1.5,
),
children: _buildLogSpans(),
),
),
if (hasErrorOrStackTrace)
AppIcon(
_isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
fill: 1,
color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.6),
),
],
),
),
if (_isExpanded && hasErrorOrStackTrace) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
if (widget.log.error != null)
_buildDetailSection(title: t.logs.error, content: widget.log.error.toString()),
if (widget.log.stackTrace != null) ...[
const SizedBox(height: 12),
_buildDetailSection(title: t.logs.stackTrace, content: widget.log.stackTrace.toString()),
],
],
],
),
],
),
),
);
}
Widget _buildDetailSection({required String title, required String content}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: widget.levelColor, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[900] : Colors.grey[200],
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: SelectableText(
content,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
);
}
}
+16 -1
View File
@@ -22,7 +22,7 @@ import '../../providers/settings_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/user_profile_provider.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../mpv/player/player_android.dart';
import '../../mpv/player/platform/player_android.dart';
import '../../services/settings_service.dart' as settings;
import '../../services/update_service.dart';
import '../../utils/snackbar_helper.dart';
@@ -71,6 +71,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
static const _kShowServerNameOnHubs = 'show_server_name_on_hubs';
static const _kAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
static const _kShowUnwatchedCount = 'show_unwatched_count';
static const _kHideSpoilers = 'hide_spoilers';
static const _kRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
static const _kConfirmExitOnBack = 'confirm_exit_on_back';
static const _kPlayerBackend = 'player_backend';
@@ -383,6 +384,20 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kHideSpoilers),
secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1),
title: Text(t.settings.hideSpoilers),
subtitle: Text(t.settings.hideSpoilersDescription),
value: settingsProvider.hideSpoilers,
onChanged: (value) async {
await settingsProvider.setHideSpoilers(value);
},
);
},
),
Consumer<UserProfileProvider>(
builder: (context, userProfileProvider, child) {
if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink();
+128 -37
View File
@@ -11,8 +11,9 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../mpv/mpv.dart';
import '../mpv/player/player_android.dart';
import '../mpv/player/platform/player_android.dart';
import '../../services/bif_thumbnail_service.dart';
import '../../services/plex_client.dart';
import '../models/livetv_channel.dart';
import '../services/plex_api_cache.dart';
@@ -20,11 +21,12 @@ import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../utils/content_utils.dart';
import '../utils/plex_cache_parser.dart';
import '../models/plex_media_info.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../models/companion_remote/remote_command_type.dart';
import '../models/companion_remote/remote_command.dart';
import '../providers/companion_remote_provider.dart';
import '../services/companion_remote/companion_remote_receiver.dart';
import '../services/fullscreen_state_manager.dart';
@@ -138,8 +140,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
bool _isDisposingForNavigation = false;
bool _waitingForExternalSubsTrackSelection = false;
bool _isApplyingTrackSelection = false;
bool _isHandlingBack = false;
bool _hasThumbnails = false;
BifThumbnailService? _bifService;
// Live TV channel navigation
int _liveChannelIndex = -1;
@@ -170,6 +173,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Screen-level focus node: persists across loading/initialized phases so
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
bool _reclaimingFocus = false;
// Cached setting: when false on Windows/Linux, ESC should not exit the player
bool _videoPlayerNavigationEnabled = false;
// App lifecycle state tracking
bool _wasPlayingBeforeInactive = false;
@@ -195,14 +202,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return context.getClientForServer(widget.metadata.serverId!);
}
String? _buildThumbnailUrl(BuildContext context, Duration time) {
final partId = _currentMediaInfo?.partId;
if (partId == null || widget.isOffline) return null;
final client = _getClientForMetadata(context);
return '${client.config.baseUrl}/library/parts/$partId/indexes/sd/${time.inMilliseconds}'.withPlexToken(
client.config.token,
);
}
Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time);
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
@@ -393,6 +393,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
try {
// Load buffer size from settings
final settingsService = await SettingsService.getInstance();
_videoPlayerNavigationEnabled = settingsService.getVideoPlayerNavigationEnabled();
final bufferSizeMB = settingsService.getBufferSize();
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
@@ -422,6 +423,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
'#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor',
);
await player!.setProperty('sub-ass-override', 'no');
await player!.setProperty('sub-ass-video-aspect-override', '1');
await player!.setProperty('sub-pos', settingsService.getSubtitlePosition().toString());
// Platform-specific settings
@@ -429,6 +431,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await player!.setProperty('audio-exclusive', 'yes');
}
// Audio passthrough (desktop only - sends bitstream to receiver)
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
if (settingsService.getAudioPassthrough()) {
await player!.setAudioPassthrough(true);
}
}
// HDR is controlled via custom hdr-enabled property on iOS/macOS/Windows
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) {
final enableHDR = settingsService.getEnableHDR();
@@ -545,6 +554,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Listen to position for completion detection (fallback for unreliable MPV events)
_positionSubscription = player!.streams.position.listen((position) {
// Fallback for cases where playbackRestart doesn't fire (observed on some
// offline Android playback flows). Prevents a permanent loading spinner.
if (!_hasFirstFrame.value && position.inMilliseconds > 0) {
_hasFirstFrame.value = true;
}
final duration = player!.state.duration;
if (duration.inMilliseconds > 0 &&
position.inMilliseconds >= duration.inMilliseconds - 1000 &&
@@ -1015,17 +1030,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
setState(() {
_availableVersions = result.availableVersions.cast();
_currentMediaInfo = result.mediaInfo;
_hasThumbnails = false;
_bifService?.dispose();
_bifService = null;
});
// Check whether any thumbnails exist by requesting the first one
// Download and cache BIF thumbnail file
if (_currentMediaInfo?.partId != null && !widget.isOffline) {
final partId = _currentMediaInfo!.partId!;
final client = _getClientForMetadata(context);
client.checkThumbnailsAvailable(partId).then((available) {
// Guard against media having changed while the probe was in flight
final service = BifThumbnailService();
service.load(client, partId).then((_) {
// Guard against media having changed while the download was in flight
if (mounted && _currentMediaInfo?.partId == partId) {
setState(() => _hasThumbnails = available);
setState(() => _bifService = service);
} else {
service.dispose();
}
});
}
@@ -1083,10 +1102,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
} on PlaybackException catch (e) {
if (mounted) {
_hasFirstFrame.value = true; // Hide spinner on error
showErrorSnackBar(context, e.message);
}
} catch (e) {
if (mounted) {
_hasFirstFrame.value = true; // Hide spinner on error
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
@@ -1131,10 +1152,29 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
appLogger.d('Starting offline playback: $videoPath');
// Load cached media info so track selection (audio language) works offline
PlexMediaInfo? mediaInfo;
try {
final serverId = widget.metadata.serverId;
if (serverId != null) {
final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${widget.metadata.ratingKey}');
final metadataJson = PlexCacheParser.extractFirstMetadata(cached);
if (metadataJson != null) {
mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson);
}
appLogger.d(
'Offline media info: cached=${cached != null}, hasMedia=${metadataJson?['Media'] != null}, '
'audioTracks=${mediaInfo?.audioTracks.length ?? 0}, subtitleTracks=${mediaInfo?.subtitleTracks.length ?? 0}',
);
}
} catch (e) {
appLogger.d('Could not load cached media info for offline playback', error: e);
}
return PlaybackInitializationResult(
availableVersions: [],
videoUrl: videoPath.contains('://') ? videoPath : 'file://$videoPath',
mediaInfo: null,
mediaInfo: mediaInfo,
externalSubtitles: const [],
isOffline: true,
);
@@ -1593,6 +1633,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoPIPManager?.onBeforeEnterPip = null;
_videoFilterManager?.dispose();
// Release cached BIF thumbnail data
_bifService?.dispose();
// Mark sleep timer for restart if truly exiting (not episode transition)
if (!_isReplacingWithVideo) {
SleepTimerService().markNeedsRestart();
@@ -1681,8 +1724,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// descendant has focus, so internal movement between child controls
/// does NOT trigger this.
void _onScreenFocusChanged() {
if (_reclaimingFocus) return;
if (!_screenFocusNode.hasFocus && mounted && !_isExiting.value) {
_reclaimingFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_reclaimingFocus = false;
if (mounted && !_isExiting.value && !_screenFocusNode.hasFocus) {
_screenFocusNode.requestFocus();
}
@@ -1694,6 +1740,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Toggle wakelock based on playback state
if (isPlaying) {
WakelockPlus.enable();
// Force a texture refresh on resume to unstick stale frames
// (Linux/macOS texture registrars can miss frame-available
// notifications after extended pause periods)
player?.updateFrame();
} else {
WakelockPlus.disable();
}
@@ -2041,26 +2091,63 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Wait briefly for profile settings to load in offline mode.
/// This prevents default-track fallback when playback starts before
/// UserProfileProvider finishes initialization.
Future<void> _waitForProfileSettingsIfNeeded() async {
if (!widget.isOffline || !mounted) return;
final provider = context.read<UserProfileProvider>();
if (provider.profileSettings != null) return;
final completer = Completer<void>();
late VoidCallback listener;
listener = () {
if (provider.profileSettings != null && !completer.isCompleted) {
completer.complete();
}
};
provider.addListener(listener);
try {
await Future.any<void>([completer.future, Future.delayed(const Duration(seconds: 2))]);
} finally {
provider.removeListener(listener);
}
}
/// Apply track selection using the TrackSelectionService
Future<void> _applyTrackSelection() async {
if (!mounted || player == null) return;
if (!mounted || player == null || _isApplyingTrackSelection) return;
final profileSettings = context.read<UserProfileProvider>().profileSettings;
final settingsService = await SettingsService.getInstance();
final trackService = TrackSelectionService(
player: player!,
profileSettings: profileSettings,
metadata: widget.metadata,
plexMediaInfo: _currentMediaInfo,
);
_isApplyingTrackSelection = true;
try {
await _waitForProfileSettingsIfNeeded();
if (!mounted || player == null) return;
await trackService.selectAndApplyTracks(
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(),
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
);
final profileSettings = context.read<UserProfileProvider>().profileSettings;
final settingsService = await SettingsService.getInstance();
if (!mounted || player == null) return;
final trackService = TrackSelectionService(
player: player!,
profileSettings: profileSettings,
metadata: widget.metadata,
plexMediaInfo: _currentMediaInfo,
);
await trackService.selectAndApplyTracks(
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(),
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
);
} catch (e) {
appLogger.w('Failed to apply track selection', error: e);
} finally {
_isApplyingTrackSelection = false;
}
}
/// Rating key used for series/movie level language preferences.
@@ -2327,7 +2414,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
canRequestFocus: isCurrentRoute,
onKeyEvent: (node, event) {
if (!isCurrentRoute) return KeyEventResult.ignored;
// Back keys always pass through — handled by PopScope (system back
// On Windows/Linux with navigation off, consume ESC so Flutter's
// DismissAction doesn't trigger a route pop. The video controls'
// global key handler manages fullscreen/controls toggle instead.
if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) {
return KeyEventResult.handled;
}
// Back keys pass through — handled by PopScope (system back
// gesture) or overlay sheet's onKeyEvent.
if (event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Self-heal: if this node itself has primary focus (no descendant
@@ -2484,9 +2577,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
shaderService: _shaderService,
// ignore: no-empty-block - setState triggers rebuild to reflect shader change
onShaderChanged: () => setState(() {}),
thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null
? (Duration time) => _buildThumbnailUrl(context, time)!
: null,
thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null,
isLive: widget.isLive,
liveChannelName: _liveChannelName,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
+111
View File
@@ -0,0 +1,111 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'plex_client.dart';
import '../utils/app_logger.dart';
/// A single BIF thumbnail entry: timestamp in milliseconds + JPEG bytes.
typedef BifEntry = ({int timestampMs, Uint8List imageBytes});
/// Parse raw BIF file bytes into a list of thumbnail entries.
///
/// BIF format:
/// - 0..7 : magic bytes (0x89 "BIF" 0x0D 0x0A 0x1A 0x0A)
/// - 8..11 : version (uint32 LE)
/// - 12..15 : image count (uint32 LE)
/// - 16..19 : timestamp multiplier (uint32 LE, ms per unit; 0 = 1000)
/// - 20..63 : reserved
/// - 64.. : index table — (imageCount + 1) entries of 8 bytes each:
/// [timestamp (uint32 LE), offset (uint32 LE)]
/// The last entry is a sentinel (timestamp = 0xFFFFFFFF).
///
/// Top-level function so it can be passed to [Isolate.run].
List<BifEntry> _parseBifBytes(Uint8List bytes) {
if (bytes.length < 64) return [];
final data = ByteData.sublistView(bytes);
// Validate magic: 0x89 B I F 0x0D 0x0A 0x1A 0x0A
const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
for (var i = 0; i < magic.length; i++) {
if (bytes[i] != magic[i]) return [];
}
final imageCount = data.getUint32(12, Endian.little);
var timestampMultiplier = data.getUint32(16, Endian.little);
if (timestampMultiplier == 0) timestampMultiplier = 1000;
// Index table starts at byte 64; each entry is 8 bytes.
// There are (imageCount + 1) entries (last is sentinel).
final indexTableSize = (imageCount + 1) * 8;
if (bytes.length < 64 + indexTableSize) return [];
final entries = <BifEntry>[];
for (var i = 0; i < imageCount; i++) {
final entryOffset = 64 + i * 8;
final timestamp = data.getUint32(entryOffset, Endian.little);
final imgOffset = data.getUint32(entryOffset + 4, Endian.little);
// Next entry's offset gives us the end of this image's data.
final nextEntryOffset = 64 + (i + 1) * 8;
final nextImgOffset = data.getUint32(nextEntryOffset + 4, Endian.little);
if (nextImgOffset <= imgOffset || nextImgOffset > bytes.length) continue;
entries.add((
timestampMs: timestamp * timestampMultiplier,
imageBytes: Uint8List.sublistView(bytes, imgOffset, nextImgOffset),
));
}
return entries;
}
/// Caches a full BIF file in memory and serves thumbnails by timestamp.
class BifThumbnailService {
List<BifEntry>? _entries;
/// Download and parse the BIF file for [partId].
/// Returns silently on failure (thumbnails simply won't be available).
Future<void> load(PlexClient client, int partId) async {
_entries = null;
try {
final bytes = await client.downloadBifFile(partId);
if (bytes == null || bytes.isEmpty) return;
_entries = await Isolate.run(() => _parseBifBytes(bytes));
} catch (e) {
appLogger.w('BIF download/parse failed', error: e);
}
}
/// Whether thumbnails have been loaded successfully.
bool get isAvailable => _entries != null && _entries!.isNotEmpty;
/// Return the JPEG bytes for the thumbnail nearest to [time].
/// Uses binary search for O(log n) lookup.
Uint8List? getThumbnail(Duration time) {
final entries = _entries;
if (entries == null || entries.isEmpty) return null;
final ms = time.inMilliseconds;
// Binary search for the largest timestamp <= ms.
var lo = 0;
var hi = entries.length - 1;
while (lo < hi) {
final mid = (lo + hi + 1) ~/ 2; // bias right
if (entries[mid].timestampMs <= ms) {
lo = mid;
} else {
hi = mid - 1;
}
}
return entries[lo].imageBytes;
}
/// Release cached data.
void dispose() {
_entries = null;
}
}
@@ -1,95 +0,0 @@
import 'dart:async';
import 'dart:convert';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
/// Service for managing recent Companion Remote sessions
class CompanionRemoteDiscoveryService {
static const String _storageKey = 'companion_remote_recent_sessions';
static const int _maxRecentSessions = 5;
final _recentSessions = <RecentRemoteSession>[];
final _recentSessionsController = StreamController<List<RecentRemoteSession>>.broadcast();
/// Stream of recent sessions
Stream<List<RecentRemoteSession>> get recentSessions => _recentSessionsController.stream;
/// Get current list of recent sessions
List<RecentRemoteSession> get currentSessions => List.unmodifiable(_recentSessions);
CompanionRemoteDiscoveryService() {
_loadRecentSessions();
}
/// Load recent sessions from storage
Future<void> _loadRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = storage.prefs.getString(_storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_recentSessions.clear();
_recentSessions.addAll(list.map((e) => RecentRemoteSession.fromJson(e as Map<String, dynamic>)));
// Sort by last connected (most recent first)
_recentSessions.sort((a, b) => b.lastConnected.compareTo(a.lastConnected));
_recentSessionsController.add(currentSessions);
appLogger.d('Loaded ${_recentSessions.length} recent remote sessions');
}
} catch (e) {
appLogger.e('Failed to load recent sessions', error: e);
}
}
/// Save recent sessions to storage
Future<void> _saveRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = jsonEncode(_recentSessions.map((e) => e.toJson()).toList());
await storage.prefs.setString(_storageKey, json);
appLogger.d('Saved ${_recentSessions.length} recent remote sessions');
} catch (e) {
appLogger.e('Failed to save recent sessions', error: e);
}
}
/// Add a session to recent list
Future<void> addRecentSession(RecentRemoteSession session) async {
// Remove existing entry for this session ID
_recentSessions.removeWhere((s) => s.sessionId == session.sessionId);
// Add new entry at the beginning
_recentSessions.insert(0, session);
// Limit to max sessions
if (_recentSessions.length > _maxRecentSessions) {
_recentSessions.removeRange(_maxRecentSessions, _recentSessions.length);
}
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Remove a session from recent list
Future<void> removeRecentSession(String sessionId) async {
_recentSessions.removeWhere((s) => s.sessionId == sessionId);
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Clear all recent sessions
Future<void> clearRecentSessions() async {
_recentSessions.clear();
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Dispose resources
Future<void> dispose() async {
await _recentSessionsController.close();
}
}

Some files were not shown because too many files have changed in this diff Show More