diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 00000000..e47a619e --- /dev/null +++ b/.github/workflows/sonar.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index a4582ff9..7feb3aab 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +/debug-info/ # Symbolication related app.*.symbols diff --git a/Casks/plezy.rb b/Casks/plezy.rb index 11738247..3d01fd88 100644 --- a/Casks/plezy.rb +++ b/Casks/plezy.rb @@ -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" diff --git a/README.md b/README.md index 0f162f02..b2234b4c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 7ea9bc96..4ef3e3b2 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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("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!!) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 597e534e..eec524f8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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?, 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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 6675b08d..4bdb1165 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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() @@ -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() + 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() - 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}")) } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 86f6a8b6..f0e83614 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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(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() + } } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index a92e7d3b..37eacdac 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -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) diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 2727eef3..00000000 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 31363756..00000000 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7d04f18d..00000000 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 4ccdb0b7..00000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index cee25601..00000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..319552c7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..d456d3be --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/splash_icon.xml b/android/app/src/main/res/drawable/splash_icon.xml new file mode 100644 index 00000000..35edc6c5 --- /dev/null +++ b/android/app/src/main/res/drawable/splash_icon.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6fee2a96..93f6386e 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,14 +1,6 @@ - - - - - - + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png deleted file mode 100644 index 823e1e79..00000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index ad0470ec..00000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 6d4c1025..00000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 5e610a3c..00000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 9d7b47e6..00000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and /dev/null differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 00000000..2f67f54d --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 229435b0..c2158f1d 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -1,4 +1,8 @@ #1a1a1a + #0E0F12 + #0E0F12 + #000000 + #F7F7F8 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index f86351da..01337e8d 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -2,6 +2,7 @@ + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 00000000..1aa7df0a --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ab983282..a4535a0b 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,8 @@ #ffffff + #F7F7F8 + #0E0F12 + #000000 + #F7F7F8 \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index dfada3ae..c927cb19 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -2,6 +2,7 @@ + + + + + + diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index f09126bb..d8c6e5be 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -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 diff --git a/assets/plezy_adaptive_foreground.svg b/assets/plezy_adaptive_foreground.svg new file mode 100644 index 00000000..257f1063 --- /dev/null +++ b/assets/plezy_adaptive_foreground.svg @@ -0,0 +1 @@ + diff --git a/assets/plezy_android_foreground.png b/assets/plezy_android_foreground.png deleted file mode 100644 index 68bda7f9..00000000 Binary files a/assets/plezy_android_foreground.png and /dev/null differ diff --git a/assets/plezy_monochrome.png b/assets/plezy_monochrome.png deleted file mode 100644 index 5e50d5bf..00000000 Binary files a/assets/plezy_monochrome.png and /dev/null differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 37b7b3cc..9c7d9fe7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -783,7 +783,7 @@ repositoryURL = "https://github.com/edde746/MPVKit"; requirement = { kind = revision; - revision = 0d0931fbbb25a3483a7edb46babd3f2f55abeefc; + revision = 2e887368b44ce1dc9e1649e7757ec62c2564e792; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dd04e7d5..7ecb3324 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "0d0931fbbb25a3483a7edb46babd3f2f55abeefc" + "revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792" } } ], diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index dd04e7d5..7ecb3324 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "0d0931fbbb25a3483a7edb46babd3f2f55abeefc" + "revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792" } } ], diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart new file mode 100644 index 00000000..89f9c551 --- /dev/null +++ b/lib/focus/focusable_action_bar.dart @@ -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 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 createState() => FocusableActionBarState(); +} + +class FocusableActionBarState extends State { + late List _focusNodes; + late List _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, + ), + ), + ), + ); + } +} diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 254c0e44..310e3143 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6b65b454..eb938889 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 2191f767..bfe8e54c 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 09d81261..b81f1c23 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index d17307d2..8e9f5558 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index de81d476..0450b81b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "외부 플레이어", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 2f717b6d..3ff7e563 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 86371601..55049c72 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a8fe3fb6..09a1d73d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 8c51862c..4824a5ea 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 2c13e988..45e2a918 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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é', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 3992dfe5..d49f792c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index fa2c8f74..24936b1e 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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' => '내장 플레이어 대신 외부 앱에서 동영상 열기', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 084aae4c..d56dd4b5 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index ef1658f5..8c1d34c5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index d8b08f44..a877e844 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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' => '在外部应用中打开视频,而不是使用内置播放器', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 520f1992..c66ac4d8 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index df3ccb2e..3349eb43 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "外部播放器", diff --git a/lib/main.dart b/lib/main.dart index d2def9fc..8dd3fb54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 = >[]; @@ -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 { + String _statusMessage = ''; + @override void initState() { super.initState(); _loadSavedCredentials(); } + void _setStatus(String message) { + if (mounted) setState(() => _statusMessage = message); + } + Future _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 { // No network — skip connection attempts and go straight to offline mode if (!hasNetwork) { + _setStatus(t.common.startingOfflineMode); await context.read().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 { 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().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().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)), + ), + ), + ), + ], ), ); } diff --git a/lib/models/companion_remote/recent_remote_session.dart b/lib/models/companion_remote/recent_remote_session.dart deleted file mode 100644 index 146aa697..00000000 --- a/lib/models/companion_remote/recent_remote_session.dart +++ /dev/null @@ -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 json) => _$RecentRemoteSessionFromJson(json); - - Map 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()}'; -} diff --git a/lib/models/companion_remote/recent_remote_session.g.dart b/lib/models/companion_remote/recent_remote_session.g.dart deleted file mode 100644 index 4a97dfe5..00000000 --- a/lib/models/companion_remote/recent_remote_session.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'recent_remote_session.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -RecentRemoteSession _$RecentRemoteSessionFromJson(Map 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 _$RecentRemoteSessionToJson(RecentRemoteSession instance) => { - 'sessionId': instance.sessionId, - 'pin': instance.pin, - 'deviceName': instance.deviceName, - 'platform': instance.platform, - 'lastConnected': instance.lastConnected.toIso8601String(), - 'hostAddress': instance.hostAddress, -}; diff --git a/lib/models/companion_remote/remote_command.dart b/lib/models/companion_remote/remote_command.dart index 233c63fd..09a69268 100644 --- a/lib/models/companion_remote/remote_command.dart +++ b/lib/models/companion_remote/remote_command.dart @@ -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; diff --git a/lib/models/companion_remote/remote_command_type.dart b/lib/models/companion_remote/remote_command_type.dart deleted file mode 100644 index d7285ca1..00000000 --- a/lib/models/companion_remote/remote_command_type.dart +++ /dev/null @@ -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, -} diff --git a/lib/models/companion_remote/trusted_device.dart b/lib/models/companion_remote/trusted_device.dart deleted file mode 100644 index e367c852..00000000 --- a/lib/models/companion_remote/trusted_device.dart +++ /dev/null @@ -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 json) => _$TrustedDeviceFromJson(json); - - Map 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; -} diff --git a/lib/models/companion_remote/trusted_device.g.dart b/lib/models/companion_remote/trusted_device.g.dart deleted file mode 100644 index 38a195dc..00000000 --- a/lib/models/companion_remote/trusted_device.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'trusted_device.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -TrustedDevice _$TrustedDeviceFromJson(Map 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 _$TrustedDeviceToJson(TrustedDevice instance) => { - 'peerId': instance.peerId, - 'deviceName': instance.deviceName, - 'platform': instance.platform, - 'firstConnected': instance.firstConnected.toIso8601String(), - 'lastConnected': instance.lastConnected.toIso8601String(), - 'isApproved': instance.isApproved, -}; diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 5b130173..1c92a219 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -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 metadata) { + final media = metadata['Media'] as List?; + if (media == null || media.isEmpty) return null; + final parts = media[0]['Part'] as List?; + if (parts == null || parts.isEmpty) return null; + final streams = parts[0]['Stream'] as List?; + + final audioTracks = []; + final subtitleTracks = []; + + 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; } } diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index a6801b6c..cf9347a1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -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 json) { + /// Extract an image URL by type from the Image array in raw JSON + static String? _extractImageFromJson(Map 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 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(); diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index cd57d744..d934f63b 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -48,6 +48,7 @@ PlexMetadata _$PlexMetadataFromJson(Map 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 json) => PlexMetadata( extraType: (json['extraType'] as num?)?.toInt(), primaryExtraKey: json['primaryExtraKey'] as String?, clearLogo: json['clearLogo'] as String?, + backgroundSquare: json['backgroundSquare'] as String?, ); Map _$PlexMetadataToJson(PlexMetadata instance) => { @@ -100,6 +102,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => _$PlexMetadataToJson(PlexMetadata instance) => _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; diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 2ff10169..494a0038 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -282,8 +282,7 @@ class PlayerNative extends PlayerBase { Future 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'); } } diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 7bb4f6cd..f7c2f9a0 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -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 Function(RemoteDevice device); class CompanionRemoteProvider with ChangeNotifier { RemoteSession? _session; CompanionRemotePeerService? _peerService; - CompanionRemoteDiscoveryService? _discoveryService; String _deviceName = 'Unknown Device'; String _platform = 'unknown'; - final List _trustedDevices = []; - final List _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? _deviceDisconnectedSubscription; StreamSubscription? _errorSubscription; StreamSubscription? _statusSubscription; - StreamSubscription>? _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 get trustedDevices => List.unmodifiable(_trustedDevices); - List get recentSessions => List.unmodifiable(_recentSessions); bool get isPlayerActive => _isPlayerActive; CompanionRemoteProvider() { _initializeDeviceInfo(); - _loadTrustedDevices(); } Future _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 _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 _loadTrustedDevices() async { - try { - final storage = await StorageService.getInstance(); - final json = storage.prefs.getString(_storageKey); - if (json != null) { - final List list = jsonDecode(json); - _trustedDevices.clear(); - _trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map))); - appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices'); - } - } catch (e) { - appLogger.e('CompanionRemote: Failed to load trusted devices', error: e); - } - } - - Future _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 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 removeTrustedDevice(String peerId) async { - _trustedDevices.removeWhere((d) => d.peerId == peerId); - await _saveTrustedDevices(); - notifyListeners(); - } - - Future 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 getLastConnectedDevicePeerId() async { - final storage = await StorageService.getInstance(); - return storage.prefs.getString(_lastDeviceKey); - } - - /// Load recent sessions - Future 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 _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 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 removeRecentSession(String sessionId) async { - if (_discoveryService != null) { - await _discoveryService!.removeRecentSession(sessionId); - } - } - - /// Clear all recent sessions - Future clearRecentSessions() async { - if (_discoveryService != null) { - await _discoveryService!.clearRecentSessions(); - } - } - @override void dispose() { _reconnectTimer?.cancel(); leaveSession(); - _recentSessionsSubscription?.cancel(); - _discoveryService?.dispose(); super.dispose(); } } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 577d0f23..dafe0c3a 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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 diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 7ad6cfe9..35737917 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -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(); } diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index fa23c45e..ef1a9068 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -28,7 +28,9 @@ class OfflineModeProvider extends ChangeNotifier { /// Updates network and server connection flags Future _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; } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index bf3cadde..a0694355 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -12,6 +12,7 @@ class SettingsProvider extends ChangeNotifier { bool _showServerNameOnHubs = false; bool _alwaysKeepSidebarOpen = false; bool _showUnwatchedCount = true; + bool _hideSpoilers = false; bool _isInitialized = false; Future? _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 _updateSetting({ required T current, @@ -122,6 +126,12 @@ class SettingsProvider extends ChangeNotifier { persist: _settingsService!.setShowUnwatchedCount, ); + Future setHideSpoilers(bool value) => _updateSetting( + current: _hideSpoilers, value: value, + setLocal: (v) => _hideSpoilers = v, + persist: _settingsService!.setHideSpoilers, + ); + String get libraryDensityDisplayName { switch (_libraryDensity) { case LibraryDensity.compact: diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 2962db09..98743ea8 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -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 _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 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: diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 5dc25f79..1589fb1f 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -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 { multiServerProvider: context.read(), librariesProvider: context.read(), syncService: context.read(), + clientIdentifier: _authService.clientIdentifier, ); if (!result.hasConnections) { @@ -112,10 +114,7 @@ class _AuthScreenState extends State { 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 { 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) ...[ diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index b7968e5e..4f9e4efe 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -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 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 getAppBarButtons() { - final buttons = []; - 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 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 _deleteCollection() async { diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 677b70b7..8d3db5d7 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -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, ), diff --git a/lib/screens/companion_remote/pairing_screen.dart b/lib/screens/companion_remote/pairing_screen.dart index a8ac03cd..f5365d43 100644 --- a/lib/screens/companion_remote/pairing_screen.dart +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -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 { final _pinController = TextEditingController(); final _formKey = GlobalKey(); bool _isConnecting = false; - String? _connectingSessionId; - bool _isDiscovering = false; String? _errorMessage; int _selectedTab = 0; @@ -36,15 +31,9 @@ class _PairingScreenState extends State { 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 { super.dispose(); } - Future _loadRecentSessions() async { - setState(() { - _isDiscovering = true; - _errorMessage = null; - }); - - try { - await context.read().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 _connectToRecentSession(RecentRemoteSession session) async { - setState(() { - _isConnecting = true; - _connectingSessionId = session.sessionId; - _errorMessage = null; - }); - - try { - await context.read().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 _connect() async { if (!_formKey.currentState!.validate()) { return; @@ -209,39 +153,30 @@ class _PairingScreenState extends State { 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( - segments: [ - ButtonSegment(value: 0, label: Text(t.companionRemote.pairing.recent), icon: const Icon(Icons.history)), - if (_isMobile) + if (_isMobile) + SegmentedButton( + 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 selection) { - setState(() { - _selectedTab = selection.first; - }); - }, - ), + ButtonSegment( + value: _manualTabIndex, + label: Text(t.companionRemote.pairing.manual), + icon: const Icon(Icons.keyboard), + ), + ], + selected: {_selectedTab}, + onSelectionChanged: (Set selection) { + setState(() { + _selectedTab = selection.first; + }); + }, + ), Expanded(child: _buildTabContent()), ], ), @@ -249,8 +184,7 @@ class _PairingScreenState extends State { } 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 { ); } - Widget _buildDiscoveryTab() { - return Consumer( - 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 _showRemoveSessionDialog(RecentRemoteSession session) async { - final confirmed = await showDialog( - 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().removeRecentSession(session.sessionId); - } - } - Widget _buildManualEntryTab() { return SingleChildScrollView( padding: const EdgeInsets.all(24.0), diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 7dbfffd6..e0ca4882 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 // 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(); /// Get the correct PlexClient for an item's server PlexClient _getClientForItem(PlexMetadata? item) { @@ -188,7 +182,7 @@ class _DiscoverScreenState extends State if (_isHeroSectionVisible) { _heroFocusNode.requestFocus(); } else { - _refreshButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } _scrollToTop(); } @@ -245,14 +239,6 @@ class _DiscoverScreenState extends State 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 _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 }, ); - /// 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(); - _showUserMenu(context, userProvider); - }, - ); - @override void dispose() { _hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged); @@ -379,14 +294,6 @@ class _DiscoverScreenState extends State _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 /// 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 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 ).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( - 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( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.isDesktop(context); + final userProvider = context.watch(); + + 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( - 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( - 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( - 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( + 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 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 } }, 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 ); } - 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 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 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 ] 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), ), ], ), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index d28d63b2..d58825b6 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -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 on State, 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 actionBarKey = GlobalKey(); // 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 getAppBarButtons(); + /// Called to get the list of app bar action configurations + List 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 on State, 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 on State, 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 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. diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index ade17ca3..ac0c288b 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -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 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(); bool _isAppBarFocused = false; bool _backHandledByKeyEvent = false; @@ -63,7 +59,6 @@ class _HubDetailScreenState extends State 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 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 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 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 _loadSorts() async { try { @@ -315,7 +280,6 @@ class _HubDetailScreenState extends State 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 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, ), - ), + ], ), ], ), diff --git a/lib/widgets/alpha_jump_bar.dart b/lib/screens/libraries/alpha_jump_bar.dart similarity index 99% rename from lib/widgets/alpha_jump_bar.dart rename to lib/screens/libraries/alpha_jump_bar.dart index f06e69bb..6fa0dd49 100644 --- a/lib/widgets/alpha_jump_bar.dart +++ b/lib/screens/libraries/alpha_jump_bar.dart @@ -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. diff --git a/lib/widgets/alpha_jump_helper.dart b/lib/screens/libraries/alpha_jump_helper.dart similarity index 97% rename from lib/widgets/alpha_jump_helper.dart rename to lib/screens/libraries/alpha_jump_helper.dart index 5c7ae1e4..ac92ad8c 100644 --- a/lib/widgets/alpha_jump_helper.dart +++ b/lib/screens/libraries/alpha_jump_helper.dart @@ -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). diff --git a/lib/widgets/alpha_scroll_handle.dart b/lib/screens/libraries/alpha_scroll_handle.dart similarity index 99% rename from lib/widgets/alpha_scroll_handle.dart rename to lib/screens/libraries/alpha_scroll_handle.dart index b34fbb26..4b097433 100644 --- a/lib/widgets/alpha_scroll_handle.dart +++ b/lib/screens/libraries/alpha_scroll_handle.dart @@ -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 diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 5c7cb83e..f6515573 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -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 _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(); // Scroll controller for the outer CustomScrollView final ScrollController _outerScrollController = ScrollController(); @@ -141,12 +139,6 @@ class _LibrariesScreenState extends State 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 _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(); - 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 _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 ) 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 ); // 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 getTabChipFocusNode(newIndex).requestFocus(); } : () { - // Navigate to first action button (edit if libraries exist, else refresh) - final librariesProvider = context.read(); - 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 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, ), - ), + ], ), ], ), diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 75bf54fd..ca588ea7 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -76,7 +76,8 @@ abstract class BaseLibraryTabState> extends State // Focus management bool _hasLoadedData = false; - bool _hasFocused = false; + @protected + bool hasFocused = false; // Getters for subclasses List get items => _items; @@ -122,7 +123,7 @@ abstract class BaseLibraryTabState> 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> 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(); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 4f6a3388..a4887a1b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -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 widget.library.serverId; @override - Set? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet(); + Set? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet(); @override Set? get deletionGlobalKeys { - if (items.isEmpty) return {}; + if (_loadedItems.isEmpty) return {}; final keys = {}; - 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 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{}; + 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 _loadedItems = {}; + final Set _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> loadData() async { @@ -240,11 +275,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadItems({bool loadMore = false}) async { - if (loadMore && isLoading) return; + /// Build the filter params map for API calls + Map _buildFilterParams() { + final filterParams = Map.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 _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.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= _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 _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= 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 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 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 _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 _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( 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 _filters.isNotEmpty && _selectedGrouping != 'folders'; @@ -976,11 +1121,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _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= 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 final _guideTabKey = GlobalKey(); final _whatsOnTabKey = GlobalKey(); - // App bar action button focus - final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); - bool _isRefreshFocused = false; + // App bar action bar + final _actionBarKey = GlobalKey(); List _channels = []; bool _isLoading = true; @@ -47,7 +46,6 @@ class _LiveTvScreenState extends State super.initState(); suppressAutoFocus = true; initTabNavigation(); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); _loadChannels(); } @@ -55,15 +53,10 @@ class _LiveTvScreenState extends State 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 @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 }); getTabChipFocusNode(newIndex).requestFocus(); } - : () => _refreshButtonFocusNode.requestFocus(), + : () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); @@ -303,20 +268,17 @@ class _LiveTvScreenState extends State ) : 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, ), - ), + ], ), ], ), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index db9cda78..a0831cf7 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -533,7 +533,7 @@ class GuideTabState extends State { children: [ Row( children: [ - SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), + const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight), Expanded( child: SingleChildScrollView( controller: _headerHorizontalController, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9fd2313c..47c18a46 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -65,6 +65,7 @@ class MediaDetailScreen extends StatefulWidget { class _MediaDetailScreenState extends State with WatchStateAware, DeletionAware { List _seasons = []; bool _isLoadingSeasons = false; + Completer? _seasonsCompleter; PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; PlexVideoPlaybackData? _playbackData; @@ -713,11 +714,11 @@ class _MediaDetailScreenState extends State 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 with WatchStateAw } Future _loadSeasons() async { + _seasonsCompleter = Completer(); setState(() { _isLoadingSeasons = true; }); @@ -1048,11 +1050,16 @@ class _MediaDetailScreenState extends State with WatchStateAw setState(() { _isLoadingSeasons = false; }); + } finally { + if (!(_seasonsCompleter?.isCompleted ?? true)) { + _seasonsCompleter?.complete(); + } } } /// Load seasons from downloaded episodes (offline mode) void _loadSeasonsFromDownloads() { + _seasonsCompleter = Completer(); setState(() { _isLoadingSeasons = true; }); @@ -1087,6 +1094,9 @@ class _MediaDetailScreenState extends State 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 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 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().getArtworkLocalPath( widget.metadata.serverId!, - metadata.art, + heroArtPath, ); if (localPath != null && File(localPath).existsSync()) { return Image.file( @@ -1879,7 +1892,7 @@ class _MediaDetailScreenState extends State 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, diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 69cca68e..a23d594a 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -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 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 getAppBarButtons() { - final buttons = []; - 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 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 diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index a8056592..e10e3c89 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -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), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 9ecc5f59..aba5376e 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -235,16 +235,16 @@ class _SearchScreenState extends State 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), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 768c7aeb..5f3dae1f 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -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 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().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( diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 3f3eaa1f..edbabbe5 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -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 { List _logs = []; + final ScrollController _scrollController = ScrollController(); @override void initState() { @@ -28,6 +30,12 @@ class _LogsScreenState extends State { _logs = MemoryLogOutput.getLogs(); } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + void _loadLogs() { setState(() { _logs = MemoryLogOutput.getLogs(); @@ -115,7 +123,7 @@ class _LogsScreenState extends State { 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 { } } - 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 _buildLogSpans() { + final spans = []; + 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'), - ), - ), - ], - ); - } } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index a7dc66a0..bb844658 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -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 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 with FocusableTab { ); }, ), + Consumer( + 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( builder: (context, userProfileProvider, child) { if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 146e1f51..6d2c0e90 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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 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 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 _isBuffering = ValueNotifier(false); // Track if video is currently buffering final ValueNotifier _hasFirstFrame = ValueNotifier(false); // Track if first video frame has rendered @@ -393,6 +393,7 @@ class VideoPlayerScreenState extends State 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 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 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 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 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 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 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 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 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 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 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 _waitForProfileSettingsIfNeeded() async { + if (!widget.isOffline || !mounted) return; + + final provider = context.read(); + if (provider.profileSettings != null) return; + + final completer = Completer(); + late VoidCallback listener; + listener = () { + if (provider.profileSettings != null && !completer.isCompleted) { + completer.complete(); + } + }; + + provider.addListener(listener); + try { + await Future.any([completer.future, Future.delayed(const Duration(seconds: 2))]); + } finally { + provider.removeListener(listener); + } + } + /// Apply track selection using the TrackSelectionService Future _applyTrackSelection() async { - if (!mounted || player == null) return; + if (!mounted || player == null || _isApplyingTrackSelection) return; - final profileSettings = context.read().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().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 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 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, diff --git a/lib/services/bif_thumbnail_service.dart b/lib/services/bif_thumbnail_service.dart new file mode 100644 index 00000000..cc616f3e --- /dev/null +++ b/lib/services/bif_thumbnail_service.dart @@ -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 _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 = []; + 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? _entries; + + /// Download and parse the BIF file for [partId]. + /// Returns silently on failure (thumbnails simply won't be available). + Future 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; + } +} diff --git a/lib/services/companion_remote/companion_remote_discovery_service.dart b/lib/services/companion_remote/companion_remote_discovery_service.dart deleted file mode 100644 index f5136df9..00000000 --- a/lib/services/companion_remote/companion_remote_discovery_service.dart +++ /dev/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 = []; - final _recentSessionsController = StreamController>.broadcast(); - - /// Stream of recent sessions - Stream> get recentSessions => _recentSessionsController.stream; - - /// Get current list of recent sessions - List get currentSessions => List.unmodifiable(_recentSessions); - - CompanionRemoteDiscoveryService() { - _loadRecentSessions(); - } - - /// Load recent sessions from storage - Future _loadRecentSessions() async { - try { - final storage = await StorageService.getInstance(); - final json = storage.prefs.getString(_storageKey); - - if (json != null) { - final List list = jsonDecode(json); - _recentSessions.clear(); - _recentSessions.addAll(list.map((e) => RecentRemoteSession.fromJson(e as Map))); - - // 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 _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 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 removeRecentSession(String sessionId) async { - _recentSessions.removeWhere((s) => s.sessionId == sessionId); - await _saveRecentSessions(); - _recentSessionsController.add(currentSessions); - } - - /// Clear all recent sessions - Future clearRecentSessions() async { - _recentSessions.clear(); - await _saveRecentSessions(); - _recentSessionsController.add(currentSessions); - } - - /// Dispose resources - Future dispose() async { - await _recentSessionsController.close(); - } -} diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index fb6f0559..a394da60 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -6,7 +6,6 @@ import 'dart:math'; import 'package:web_socket_channel/io.dart'; import '../../models/companion_remote/remote_command.dart'; -import '../../models/companion_remote/remote_command_type.dart'; import '../../models/companion_remote/remote_session.dart'; import '../../utils/app_logger.dart'; diff --git a/lib/services/companion_remote/companion_remote_receiver.dart b/lib/services/companion_remote/companion_remote_receiver.dart index bd31cb77..d04d80d7 100644 --- a/lib/services/companion_remote/companion_remote_receiver.dart +++ b/lib/services/companion_remote/companion_remote_receiver.dart @@ -2,7 +2,6 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../../models/companion_remote/remote_command.dart'; -import '../../models/companion_remote/remote_command_type.dart'; import '../../utils/app_logger.dart'; import '../../utils/key_event_simulator.dart'; diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index b0963025..948e98ee 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -84,8 +84,13 @@ class DataAggregationService { return []; } + // For global hubs, fetch libraries to split "Recently Added" hubs by library + final libraries = useGlobalHubs + ? (librariesByServer ?? groupLibrariesByServer(await getLibrariesFromAllServers())) + : librariesByServer; + return useGlobalHubs - ? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys) + ? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys, librariesByServer: libraries) : _fetchLibraryHubs( clients, limit: limit, @@ -99,6 +104,7 @@ class DataAggregationService { Map clients, { int? limit, Set? hiddenLibraryKeys, + Map>? librariesByServer, }) async { appLogger.d('Fetching global hubs from ${clients.length} servers'); @@ -150,7 +156,9 @@ class DataAggregationService { }); final results = await Future.wait(hubFutures); - final result = _collectAndLimitResults(results, limit); + // Split "Recently Added" hubs that combine items from multiple libraries + final splitResults = results.map((hubs) => _splitRecentlyAddedHubs(hubs, librariesByServer)).toList(); + final result = _collectAndLimitResults(splitResults, limit); appLogger.i('Fetched ${result.length} global hubs from all servers'); @@ -300,6 +308,103 @@ class DataAggregationService { return limit != null && limit < all.length ? all.sublist(0, limit) : all; } + /// Split "Recently Added" hubs that contain items from multiple libraries + /// into separate per-library hubs, matching the official Plex client behavior. + List _splitRecentlyAddedHubs( + List hubs, + Map>? librariesByServer, + ) { + final result = []; + + for (final hub in hubs) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + if (!hubId.contains('.recent')) { + result.add(hub); + continue; + } + + // Group items by librarySectionID + final groups = >{}; + final ungrouped = []; + + for (final item in hub.items) { + final sectionId = item.librarySectionID; + if (sectionId == null) { + ungrouped.add(item); + } else { + groups.putIfAbsent(sectionId, () => []).add(item); + } + } + + // Single library (or no groupable items) — keep hub unchanged + if (groups.length <= 1) { + result.add(hub); + continue; + } + + // Multiple libraries — create one hub per library + for (final entry in groups.entries) { + final items = entry.value; + final libraryName = _resolveLibraryName(items.first, librariesByServer); + final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title; + + result.add(PlexHub( + hubKey: hub.hubKey, + title: title, + type: hub.type, + hubIdentifier: '${hub.hubIdentifier}_${entry.key}', + size: items.length, + more: hub.more, + items: items, + serverId: hub.serverId, + serverName: hub.serverName, + )); + } + + // Keep ungrouped items in a hub with the original title + if (ungrouped.isNotEmpty) { + result.add(PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: ungrouped.length, + more: hub.more, + items: ungrouped, + serverId: hub.serverId, + serverName: hub.serverName, + )); + } + } + + return result; + } + + /// Resolve library name from item metadata or library lookup map. + String? _resolveLibraryName( + PlexMetadata item, + Map>? librariesByServer, + ) { + // Try librarySectionTitle from the item itself (Plex API often includes it) + if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) { + return item.librarySectionTitle; + } + + // Fall back to library lookup + if (librariesByServer != null && item.serverId != null && item.librarySectionID != null) { + final serverLibraries = librariesByServer[item.serverId]; + if (serverLibraries != null) { + for (final lib in serverLibraries) { + if (lib.key == item.librarySectionID.toString()) { + return lib.title; + } + } + } + } + + return null; + } + /// Base helper for per-server fan-out operations /// /// Returns raw results as (serverId, result) tuples. diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index ab37a94a..b73b97b7 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -309,9 +309,47 @@ class DownloadManagerService { appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)'); } + // One-time migration: normalize stored file paths that may contain a + // doubled base-dir prefix from an earlier bug in the recovery callback. + // Re-run on v2 to also fix paths without a leading / that the v1 migration missed. + final prefs = (await SettingsService.getInstance()).prefs; + if ((prefs.getInt('download_paths_normalized_version') ?? 0) < 2) { + final allItems = await _database.select(_database.downloadedMedia).get(); + var fixed = 0; + for (final item in allItems) { + if (item.videoFilePath != null) { + final vfp = item.videoFilePath!; + var normalized = await _storageService.toRelativePath(vfp); + // If toRelativePath didn't help, try extracting from downloads/ onward + // for paths that lack a leading / but contain nested base-dir fragments + if (normalized == vfp) { + final idx = vfp.indexOf('downloads/'); + if (idx > 0) normalized = vfp.substring(idx); + } + appLogger.d('Path migration: videoFilePath="$vfp", normalized="$normalized"'); + if (normalized != vfp) { + await _database.updateVideoFilePath(item.globalKey, normalized); + fixed++; + } + } + if (item.thumbPath != null) { + final tp = item.thumbPath!; + var normalized = await _storageService.toRelativePath(tp); + if (normalized == tp) { + final idx = tp.indexOf('downloads/'); + if (idx > 0) normalized = tp.substring(idx); + } + if (normalized != tp) { + await _database.updateArtworkPaths(globalKey: item.globalKey, thumbPath: normalized); + } + } + } + if (fixed > 0) appLogger.i('Normalized $fixed corrupted download path(s)'); + await prefs.setInt('download_paths_normalized_version', 2); + } + // Scan drift for orphaned items stuck in 'downloading' final allDownloads = await _database.select(_database.downloadedMedia).get(); - for (final item in allDownloads) { if (item.status == DownloadStatus.downloading.index) { // Video already downloaded but post-processing didn't complete @@ -451,9 +489,15 @@ class DownloadManagerService { status: DownloadStatus.queued.index, ); - // Pin the already-cached API response for offline use - // (getMetadataWithImages was already called by download_provider, which cached with chapters/markers) - await _apiCache.pinForOffline(metadata.serverId!, metadata.ratingKey); + // Ensure metadata is in cache before pinning. + // Normally getMetadataWithImages already cached the full API response (with chapters/markers), + // but if the network failed during the provider's fetch, the cache entry may not exist. + final cached = await _apiCache.get(metadata.serverId!, '/library/metadata/${metadata.ratingKey}'); + if (cached == null) { + await _cacheMetadataForOffline(metadata.serverId!, metadata.ratingKey, metadata); + } else { + await _apiCache.pinForOffline(metadata.serverId!, metadata.ratingKey); + } // Add to queue await _database.addToQueue( @@ -501,11 +545,31 @@ class DownloadManagerService { final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; - final metadata = await _apiCache.getMetadata(serverId, ratingKey); - if (metadata == null) throw Exception('Metadata not found in cache for $globalKey'); + var metadata = await _apiCache.getMetadata(serverId, ratingKey); + if (metadata == null) { + // Cache miss — try re-fetching from server (cache may have been cleared between queue and prepare) + appLogger.w('Cache miss for $globalKey, attempting network re-fetch'); + try { + final fetched = await client.getMetadataWithImages(ratingKey); + if (fetched != null) metadata = fetched.copyWith(serverId: serverId); + } catch (e) { + appLogger.w('Network re-fetch failed for $globalKey', error: e); + } + if (metadata == null) { + throw Exception('Metadata not found in cache and could not be fetched for $globalKey'); + } + } - final playbackData = await client.getVideoPlaybackData(metadata.ratingKey); - if (playbackData.videoUrl == null) throw Exception('Could not get video URL'); + var playbackData = await client.getVideoPlaybackData(metadata.ratingKey); + if (playbackData.videoUrl == null) { + // Cache may contain a synthetic entry (from _cacheMetadataForOffline) without + // Media/Part data. Force a fresh network fetch to populate the cache properly. + appLogger.w('No video URL from cache for $globalKey, retrying via network'); + final fetched = await client.getMetadataWithImages(ratingKey); + if (fetched != null) metadata = fetched.copyWith(serverId: serverId); + playbackData = await client.getVideoPlaybackData(metadata.ratingKey); + if (playbackData.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); + } final ext = _getExtensionFromUrl(playbackData.videoUrl!) ?? 'mp4'; @@ -890,6 +954,11 @@ class DownloadManagerService { await _downloadSingleArtwork(serverId, metadata.art!, client); } + // Download square background art + if (metadata.backgroundSquare != null) { + await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + } + // Store thumb reference in database (primary artwork for display) await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: metadata.thumb); @@ -951,6 +1020,11 @@ class DownloadManagerService { if (metadata.art != null) { await _downloadSingleArtwork(serverId, metadata.art!, client); } + + // Download square background art + if (metadata.backgroundSquare != null) { + await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + } } /// Download chapter thumbnail images for a media item diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 0c6d84c1..b3e10bf3 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../models/plex_metadata.dart'; +import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; @@ -222,7 +223,11 @@ class DownloadStorageService { String _sanitizeFileName(String name) { // Remove invalid filesystem characters: < > : " / \ | ? * // Also remove leading/trailing whitespace and dots - return name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '').replaceAll(RegExp(r'^\.+|\.+$'), '').trim(); + return name + .replaceAll(RegExp(r'[<>:"/\\|?*]'), '') + .replaceAll(RegExp(r'^\.+|\.+$'), '') + .replaceAll('.', '_') + .trim(); } /// Ensure a directory exists, creating it if necessary @@ -365,15 +370,17 @@ class DownloadStorageService { Future toRelativePath(String absolutePath) async { final baseDir = await _getBaseAppDir(); - // If the path starts with the base directory, strip it - if (absolutePath.startsWith(baseDir.path)) { - // Remove the base path and any leading separator - var relative = absolutePath.substring(baseDir.path.length); - if (relative.startsWith('/') || relative.startsWith('\\')) { - relative = relative.substring(1); + // Strip the base directory prefix iteratively — background_downloader + // recovery paths can contain the base dir doubled (e.g. + // /data/.../app_flutter/data/.../app_flutter/downloads/...). + var result = absolutePath; + while (result.startsWith(baseDir.path)) { + result = result.substring(baseDir.path.length); + if (result.startsWith('/') || result.startsWith('\\')) { + result = result.substring(1); } - return relative; } + if (result != absolutePath) return result; // Already relative or from a different base - return as-is return absolutePath; @@ -392,25 +399,73 @@ class DownloadStorageService { } /// Convert a potentially absolute path (from old database entries) to absolute - /// This handles both old absolute paths and new relative paths + /// This handles both old absolute paths and new relative paths, including + /// corrupted paths that contain nested base-dir fragments without a leading slash + /// (e.g. "data/user/0/.../app_flutter/downloads/..."). Future ensureAbsolutePath(String storedPath) async { - if (path.isAbsolute(storedPath)) { - // Already absolute - check if file exists at this path - if (await File(storedPath).exists()) { - return storedPath; + appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); + final baseDir = await _getBaseAppDir(); + final normalizedCandidates = []; + + void addCandidate(String candidate) { + if (candidate.isEmpty) return; + final normalized = path.normalize(candidate); + if (!normalizedCandidates.contains(normalized)) { + normalizedCandidates.add(normalized); } - // File doesn't exist at absolute path - try to reconstruct - // Extract the relative portion (everything after 'downloads/') - final downloadsIndex = storedPath.indexOf('downloads/'); + } + + String trimLeadingSeparators(String value) => value.replaceFirst(RegExp(r'^[\\/]+'), ''); + + if (path.isAbsolute(storedPath)) { + // Keep the original absolute path first (covers valid custom download paths). + addCandidate(storedPath); + + // Recover from doubled app base path corruption: + // /data/.../app_flutter/data/.../app_flutter/downloads/... + final firstBaseIndex = storedPath.indexOf(baseDir.path); + final secondBaseIndex = storedPath.indexOf(baseDir.path, firstBaseIndex + baseDir.path.length); + if (firstBaseIndex != -1 && secondBaseIndex != -1) { + final tail = trimLeadingSeparators(storedPath.substring(secondBaseIndex + baseDir.path.length)); + addCandidate(path.join(baseDir.path, tail)); + } + + // Recover from paths that contain downloads/ but wrong prefix. + final downloadsIndex = storedPath.lastIndexOf('downloads/'); if (downloadsIndex != -1) { final relativePart = storedPath.substring(downloadsIndex); - return await toAbsolutePath(relativePart); + addCandidate(await toAbsolutePath(relativePart)); + } + } else { + // Normal relative path. + addCandidate(await toAbsolutePath(storedPath)); + + // Recover from nested base-dir fragment without leading slash. + final baseIndex = storedPath.indexOf(baseDir.path); + if (baseIndex > 0) { + final tail = trimLeadingSeparators(storedPath.substring(baseIndex + baseDir.path.length)); + addCandidate(path.join(baseDir.path, tail)); + } + + // Recover from nested fragment containing downloads/. + final downloadsIndex = storedPath.lastIndexOf('downloads/'); + if (downloadsIndex >= 0) { + addCandidate(await toAbsolutePath(storedPath.substring(downloadsIndex))); } - // Can't reconstruct, return original - return storedPath; } - // Relative path - convert to absolute - return await toAbsolutePath(storedPath); + + // Prefer the first candidate that exists on disk. + for (final candidate in normalizedCandidates) { + if (await File(candidate).exists()) { + appLogger.d('ensureAbsolutePath: resolved="$candidate"'); + return candidate; + } + } + + // Fall back to the most conservative candidate if none currently exist. + final fallback = normalizedCandidates.isNotEmpty ? normalizedCandidates.first : await toAbsolutePath(storedPath); + appLogger.d('ensureAbsolutePath: resolved="$fallback" (fallback)'); + return fallback; } /// Calculate total storage used by downloads diff --git a/lib/services/fullscreen_window_delegate.dart b/lib/services/fullscreen_window_delegate.dart deleted file mode 100644 index 276cd70a..00000000 --- a/lib/services/fullscreen_window_delegate.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'fullscreen_state_manager.dart'; -import 'macos_window_delegate.dart'; - -/// Custom window delegate that manages fullscreen state -/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled -/// directly in Swift's WindowDelegate. This class only updates Dart-side state. -class FullscreenWindowDelegate extends MacOSWindowDelegate { - @override - void windowWillEnterFullScreen() { - FullscreenStateManager().setFullscreen(true); - } - - @override - void windowDidExitFullScreen() { - FullscreenStateManager().setFullscreen(false); - } -} diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart new file mode 100644 index 00000000..293a9d80 --- /dev/null +++ b/lib/services/image_cache_service.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:http/io_client.dart'; + +/// Custom cache manager for Plex image transcoding with connection limiting. +/// +/// Limits concurrent HTTP connections to 6 per host (matching browser HTTP/1.1 +/// behavior) to prevent overwhelming the Plex server's transcode pipeline when +/// many posters are visible simultaneously. +class PlexImageCacheManager extends CacheManager with ImageCacheManager { + static const _key = 'plexImageCache'; + + static final PlexImageCacheManager instance = PlexImageCacheManager._(); + + PlexImageCacheManager._() + : super( + Config( + _key, + stalePeriod: const Duration(days: 30), + maxNrOfCacheObjects: 5000, + fileService: HttpFileService( + httpClient: IOClient( + HttpClient()..maxConnectionsPerHost = 6, + ), + ), + ), + ); +} diff --git a/lib/services/macos_titlebar_service.dart b/lib/services/macos_titlebar_service.dart deleted file mode 100644 index cc302806..00000000 --- a/lib/services/macos_titlebar_service.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'dart:io' show Platform; -import 'fullscreen_window_delegate.dart'; -import 'macos_window_service.dart'; - -/// Service to manage macOS titlebar configuration -class MacOSTitlebarService { - static bool _initialized = false; - - /// Initialize the custom titlebar setup. - /// - /// Note: The initial window configuration (transparent titlebar, toolbar, - /// button positions, fullscreen presentation options) is now applied in - /// MainFlutterWindow.swift / WindowDelegate.swift BEFORE frame restoration - /// to prevent the window from shrinking on launch. - /// - /// This method only sets up the Dart-side callbacks. - static Future setupCustomTitlebar() async { - if (!Platform.isMacOS || _initialized) return; - _initialized = true; - - await MacOSWindowService.initialize(enableWindowDelegate: true); - final delegate = FullscreenWindowDelegate(); - MacOSWindowService.addWindowDelegate(delegate); - } -} diff --git a/lib/services/macos_window_delegate.dart b/lib/services/macos_window_delegate.dart deleted file mode 100644 index 215b032a..00000000 --- a/lib/services/macos_window_delegate.dart +++ /dev/null @@ -1,20 +0,0 @@ -/// Abstract class for receiving macOS window delegate callbacks. -/// Extend this class and register with MacOSWindowService to receive -/// fullscreen transition events. -abstract class MacOSWindowDelegate { - /// Called when the window is about to enter fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillEnterFullScreen() {} - - /// Called when the window has entered fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidEnterFullScreen() {} - - /// Called when the window is about to exit fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillExitFullScreen() {} - - /// Called when the window has exited fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidExitFullScreen() {} -} diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index c301c866..770c6616 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -1,6 +1,27 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; -import 'macos_window_delegate.dart'; +import 'fullscreen_state_manager.dart'; + +/// Abstract class for receiving macOS window delegate callbacks. +/// Extend this class and register with [MacOSWindowService] to receive +/// fullscreen transition events. +abstract class MacOSWindowDelegate { + /// Called when the window is about to enter fullscreen mode. + // ignore: no-empty-block - default no-op, subclasses override as needed + void windowWillEnterFullScreen() {} + + /// Called when the window has entered fullscreen mode. + // ignore: no-empty-block - default no-op, subclasses override as needed + void windowDidEnterFullScreen() {} + + /// Called when the window is about to exit fullscreen mode. + // ignore: no-empty-block - default no-op, subclasses override as needed + void windowWillExitFullScreen() {} + + /// Called when the window has exited fullscreen mode. + // ignore: no-empty-block - default no-op, subclasses override as needed + void windowDidExitFullScreen() {} +} /// Service for manipulating macOS window properties. /// This is a native implementation replacing the macos_window_utils package. @@ -45,6 +66,21 @@ class MacOSWindowService { // MARK: - Initialization + /// Initialize the window service and set up the titlebar. + /// + /// Note: The initial window configuration (transparent titlebar, toolbar, + /// button positions, fullscreen presentation options) is now applied in + /// MainFlutterWindow.swift / WindowDelegate.swift BEFORE frame restoration + /// to prevent the window from shrinking on launch. + /// + /// This method sets up the Dart-side callbacks for fullscreen state tracking. + static Future setupCustomTitlebar() async { + if (!Platform.isMacOS || _initialized) return; + + await initialize(enableWindowDelegate: true); + addWindowDelegate(_FullscreenWindowDelegate()); + } + /// Initialize the window service. /// Must be called before using other methods. /// Set [enableWindowDelegate] to true to receive fullscreen callbacks. @@ -94,3 +130,18 @@ class MacOSWindowService { return await _channel.invokeMethod('isFullscreen') ?? false; } } + +/// Internal window delegate that manages fullscreen state. +/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled +/// directly in Swift's WindowDelegate. This class only updates Dart-side state. +class _FullscreenWindowDelegate extends MacOSWindowDelegate { + @override + void windowWillEnterFullScreen() { + FullscreenStateManager().setFullscreen(true); + } + + @override + void windowDidExitFullScreen() { + FullscreenStateManager().setFullscreen(false); + } +} diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 8e6b99d6..4b79c75d 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -83,7 +83,7 @@ class MultiServerManager { final cachedEndpoint = storage.getServerEndpoint(serverId); // Find best working connection, passing cached endpoint for fast-path - final streamIterator = StreamIterator(server.findBestWorkingConnection(preferredUri: cachedEndpoint)); + final streamIterator = StreamIterator(server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier)); if (!await streamIterator.moveNext()) { throw Exception('No working connection found'); @@ -288,7 +288,9 @@ class MultiServerManager { } } - /// Test connection health for all servers + /// Test connection health for all servers. + /// Uses [PlexClient.isHealthy] which checks for HTTP 200, so servers with + /// invalid tokens (401) are correctly reported as offline. Future checkServerHealth() async { appLogger.d('Checking health for ${_clients.length} servers'); @@ -296,13 +298,10 @@ class MultiServerManager { final serverId = entry.key; final client = entry.value; - try { - // Simple ping by fetching server identity - await client.getServerIdentity(); - updateServerStatus(serverId, true); - } catch (e) { - appLogger.w('Server $serverId health check failed: $e'); - updateServerStatus(serverId, false); + final healthy = await client.isHealthy(); + updateServerStatus(serverId, healthy); + if (!healthy) { + appLogger.w('Server $serverId health check failed'); } }); @@ -389,7 +388,7 @@ class MultiServerManager { try { appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason}); - await for (final connection in server.findBestWorkingConnection(preferredUri: cachedEndpoint)) { + await for (final connection in server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: _clientIdentifier)) { final newUrl = connection.uri; // Check if this is actually a better connection than current diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 2428f308..24597798 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -43,6 +43,9 @@ class PlaybackProgressTracker { /// Timer ticks to skip before retrying after failures (exponential backoff). int _ticksToSkip = 0; + /// Counts timer ticks while paused to send periodic "paused" heartbeats. + int _pausedTickCounter = 0; + PlaybackProgressTracker({ required this.client, required this.metadata, @@ -70,6 +73,7 @@ class PlaybackProgressTracker { _progressTimer = Timer.periodic(updateInterval, (timer) { if (player.state.playing) { + _pausedTickCounter = 0; // Skip ticks when backing off after consecutive failures to avoid // flooding the network with doomed requests during an outage. if (_ticksToSkip > 0) { @@ -77,6 +81,18 @@ class PlaybackProgressTracker { return; } _sendProgress('playing'); + } else { + // Send periodic "paused" updates to keep the Plex session alive + // (~60s with default 10s interval) + _pausedTickCounter++; + if (_pausedTickCounter >= 6) { + _pausedTickCounter = 0; + if (_ticksToSkip > 0) { + _ticksToSkip--; + return; + } + _sendProgress('paused'); + } } }); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index ef715099..2297c449 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -51,10 +51,9 @@ class PlexAuthService { static Future create() async { final storage = await StorageService.getInstance(); - final dio = Dio(BaseOptions( - connectTimeout: ConnectionTimeouts.plexTvConnect, - receiveTimeout: ConnectionTimeouts.plexTvReceive, - )); + final dio = Dio( + BaseOptions(connectTimeout: ConnectionTimeouts.plexTvConnect, receiveTimeout: ConnectionTimeouts.plexTvReceive), + ); // Get or create client identifier String? clientIdentifier = storage.getClientIdentifier(); @@ -276,7 +275,7 @@ class PlexServer { factory PlexServer.fromJson(Map json) { // Validate required fields first if (!_isValidServerJson(json)) { - throw FormatException( + throw const FormatException( 'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)', ); } @@ -302,7 +301,7 @@ class PlexServer { // If no valid connections were parsed, this server is unusable if (connections.isEmpty) { - throw FormatException('Server has no valid connections'); + throw const FormatException('Server has no valid connections'); } DateTime? lastSeenAt; @@ -392,7 +391,7 @@ class PlexServer { /// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency /// Tests both plex.direct URI and direct IP for each connection /// HTTPS connections are tested first, with HTTP as fallback - Stream findBestWorkingConnection({String? preferredUri}) async* { + Stream findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* { if (connections.isEmpty) { appLogger.w('No connections available for server discovery'); return; @@ -438,6 +437,7 @@ class PlexServer { cachedCandidate.url, accessToken, timeout: preferredTimeout, + clientIdentifier: clientIdentifier, ); if (result.success) { @@ -457,7 +457,7 @@ class PlexServer { appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates}); for (final candidate in candidates) { - PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout).then((result) { + PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout, clientIdentifier: clientIdentifier).then((result) { completedTests++; if (!result.success) { @@ -502,7 +502,7 @@ class PlexServer { } // Attempt HTTPS upgrade on the Phase 1 winner before emitting - final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate); + final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate, clientIdentifier: clientIdentifier); final emitCandidate = upgradedFirstCandidate ?? firstCandidate; final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url); @@ -524,7 +524,7 @@ class PlexServer { await Future.wait( candidates.map((candidate) async { - final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2); + final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2, clientIdentifier: clientIdentifier); if (result.success) { candidateResults[candidate] = result; @@ -548,7 +548,7 @@ class PlexServer { // Emit the best connection if it's different from the first one if (bestCandidate != null) { - final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? bestCandidate; + final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate; final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url); if (bestConnection.uri != firstConnection.uri) { @@ -666,7 +666,7 @@ class PlexServer { return urls; } - Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate) async { + Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate, {String? clientIdentifier}) async { final currentUrl = candidate.url; if (currentUrl.startsWith('https://')) { return null; @@ -716,6 +716,7 @@ class PlexServer { httpsUrl, accessToken, timeout: ConnectionTimeouts.connectionRace, + clientIdentifier: clientIdentifier, ); if (!result.success) { @@ -863,7 +864,7 @@ class PlexConnection { factory PlexConnection.fromJson(Map json) { // Validate required fields if (!_isValidConnectionJson(json)) { - throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)'); + throw const FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)'); } return PlexConnection( diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3e62335e..b22b3f32 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui' show VoidCallback; import 'package:dio/dio.dart'; @@ -32,6 +33,13 @@ import '../utils/plex_url_helper.dart'; import '../utils/watch_state_notifier.dart'; import 'plex_api_cache.dart'; +/// Result of a paginated library content fetch +class LibraryContentResult { + final List items; + final int totalSize; + const LibraryContentResult({required this.items, required this.totalSize}); +} + /// Process hub JSON response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. List _processHubResponse(String jsonStr, String serverId, String? serverName) { @@ -208,6 +216,7 @@ class PlexClient { String baseUrl, String token, { Duration timeout = const Duration(seconds: 5), + String? clientIdentifier, }) async { final stopwatch = Stopwatch()..start(); @@ -223,10 +232,17 @@ class PlexClient { ), ); - final response = await dio.get('/', options: Options(headers: {'X-Plex-Token': token})); + final headers = {'X-Plex-Token': token}; + if (clientIdentifier != null) { + headers['X-Plex-Client-Identifier'] = clientIdentifier; + headers['X-Plex-Product'] = 'Plezy'; + headers['X-Plex-Device-Name'] = 'Plezy'; + } + + final response = await dio.get('/', options: Options(headers: headers)); stopwatch.stop(); - final success = response.statusCode == 200 || response.statusCode == 401; + final success = response.statusCode == 200; return ConnectionTestResult( success: success, @@ -259,11 +275,17 @@ class PlexClient { String token, { int attempts = 3, Duration timeout = const Duration(seconds: 5), + String? clientIdentifier, }) async { final results = []; for (int i = 0; i < attempts; i++) { - final result = await testConnectionWithLatency(baseUrl, token, timeout: timeout); + final result = await testConnectionWithLatency( + baseUrl, + token, + timeout: timeout, + clientIdentifier: clientIdentifier, + ); // If any attempt fails, return failed result immediately if (!result.success) { @@ -364,6 +386,17 @@ class PlexClient { return response.data; } + /// Check if the server connection is healthy (reachable AND authenticated). + /// Returns true only if the server responds with HTTP 200. + Future isHealthy() async { + try { + final response = await _dio.get('/identity'); + return response.statusCode == 200; + } catch (e) { + return false; + } + } + /// Get library sections /// Returns libraries automatically tagged with this client's serverId and serverName Future> getLibraries() async { @@ -372,7 +405,7 @@ class PlexClient { } /// Get library content by section ID - Future> getLibraryContent( + Future getLibraryContent( String sectionId, { int? start, int? size, @@ -394,7 +427,11 @@ class PlexClient { cancelToken: cancelToken, ); - return _extractMetadataList(response); + final items = _extractMetadataList(response); + final container = _getMediaContainer(response); + final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length; + + return LibraryContentResult(items: items, totalSize: totalSize); } /// Parse list of PlexMetadata from a cached response @@ -896,17 +933,20 @@ class PlexClient { return '${config.baseUrl}/$path'.withPlexToken(config.token); } - /// Check whether thumbnail previews are available for a given part. - /// Returns true if the server responds with 200 to the first thumbnail. - Future checkThumbnailsAvailable(int partId) async { + /// Download the full BIF (Base Index Frames) file for a given part. + /// Returns the raw bytes, or null on failure. + Future downloadBifFile(int partId) async { try { - final response = await _dio.get( - '/library/parts/$partId/indexes/sd/0', - options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 5)), + final response = await _dio.get>( + '/library/parts/$partId/indexes/sd', + options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 30)), ); - return response.statusCode == 200; + if (response.statusCode == 200 && response.data != null) { + return Uint8List.fromList(response.data!); + } + return null; } catch (_) { - return false; + return null; } } @@ -1555,19 +1595,24 @@ class PlexClient { /// Set artwork from a URL (can be a Plex internal path or external URL) Future setArtworkFromUrl(String ratingKey, String element, String url) { + final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( - () => _dio.post('/library/metadata/$ratingKey/$element', queryParameters: {'url': url}), + () => _dio.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}), 'Failed to set artwork from URL', ); } /// Upload artwork from binary data Future uploadArtwork(String ratingKey, String element, List bytes) { + final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( - () => _dio.post( - '/library/metadata/$ratingKey/$element', + () => _dio.put( + '/library/metadata/$ratingKey/$setElement', data: bytes, - options: Options(headers: {'Content-Length': bytes.length}), + options: Options( + headers: {'Content-Length': bytes.length}, + contentType: 'application/octet-stream', + ), ), 'Failed to upload artwork', ); diff --git a/lib/services/server_registry.dart b/lib/services/server_registry.dart index 954dfea3..3be80d78 100644 --- a/lib/services/server_registry.dart +++ b/lib/services/server_registry.dart @@ -1,9 +1,13 @@ import 'dart:convert'; +import 'package:dio/dio.dart'; + import '../utils/app_logger.dart'; import 'plex_auth_service.dart'; import 'storage_service.dart'; +enum ServerRefreshResult { success, networkError, authError, noToken } + /// Centralized server configuration registry /// Manages which servers are available and their configurations class ServerRegistry { @@ -95,13 +99,15 @@ class ServerRegistry { appLogger.i('Cleared all servers from registry'); } - /// Refresh servers from Plex API and update storage - /// This updates connection info (IPs, ports) that may have changed - Future refreshServersFromApi() async { + /// Refresh servers from Plex API and update storage. + /// This updates connection info (IPs, ports) that may have changed. + /// Returns [ServerRefreshResult.authError] when the stored token is rejected + /// (e.g. after removing a Plex profile PIN), so the caller can redirect to re-auth. + Future refreshServersFromApi() async { final token = _storage.getPlexToken(); if (token == null || token.isEmpty) { appLogger.d('No Plex token available, skipping server refresh'); - return; + return ServerRefreshResult.noToken; } try { @@ -111,7 +117,7 @@ class ServerRegistry { if (freshServers.isEmpty) { appLogger.w('API returned no servers, keeping existing data'); - return; + return ServerRefreshResult.success; } // Get existing servers to preserve any local-only data @@ -133,9 +139,17 @@ class ServerRegistry { await saveServers(updatedServers); appLogger.i('Refreshed ${updatedServers.length} servers from API'); + return ServerRefreshResult.success; + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + appLogger.w('Plex token is invalid (401), re-authentication required'); + return ServerRefreshResult.authError; + } + appLogger.w('Failed to refresh servers from API, using cached data', error: e); + return ServerRefreshResult.networkError; } catch (e, stackTrace) { appLogger.w('Failed to refresh servers from API, using cached data', error: e, stackTrace: stackTrace); - // Don't rethrow - we can continue with cached servers + return ServerRefreshResult.networkError; } } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index cb68e9e5..3fd86334 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -70,6 +70,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyUseExoPlayer = 'use_exoplayer'; static const String _keyAlwaysKeepSidebarOpen = 'always_keep_sidebar_open'; static const String _keyShowUnwatchedCount = 'show_unwatched_count'; + static const String _keyHideSpoilers = 'hide_spoilers'; static const String _keyGlobalShaderPreset = 'global_shader_preset'; static const String _keyRequireProfileSelectionOnOpen = 'require_profile_selection_on_open'; static const String _keyUseExternalPlayer = 'use_external_player'; @@ -77,6 +78,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyCustomExternalPlayers = 'custom_external_players'; static const String _keyConfirmExitOnBack = 'confirm_exit_on_back'; static const String _keyAmbientLighting = 'ambient_lighting'; + static const String _keyAudioPassthrough = 'audio_passthrough'; SettingsService._(); @@ -397,27 +399,27 @@ class SettingsService extends BaseSharedPreferencesService { // HotKey Objects (New implementation) Map getDefaultKeyboardHotkeys() { return { - 'play_pause': HotKey(key: PhysicalKeyboardKey.space), - 'volume_up': HotKey(key: PhysicalKeyboardKey.arrowUp), - 'volume_down': HotKey(key: PhysicalKeyboardKey.arrowDown), - 'seek_forward': HotKey(key: PhysicalKeyboardKey.arrowRight), - 'seek_backward': HotKey(key: PhysicalKeyboardKey.arrowLeft), - 'seek_forward_large': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), - 'seek_backward_large': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), - 'fullscreen_toggle': HotKey(key: PhysicalKeyboardKey.keyF), - 'mute_toggle': HotKey(key: PhysicalKeyboardKey.keyM), - 'subtitle_toggle': HotKey(key: PhysicalKeyboardKey.keyS), - 'audio_track_next': HotKey(key: PhysicalKeyboardKey.keyA), - 'subtitle_track_next': HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), - 'chapter_next': HotKey(key: PhysicalKeyboardKey.keyN), - 'chapter_previous': HotKey(key: PhysicalKeyboardKey.keyP), - 'speed_increase': HotKey(key: PhysicalKeyboardKey.equal), - 'speed_decrease': HotKey(key: PhysicalKeyboardKey.minus), - 'speed_reset': HotKey(key: PhysicalKeyboardKey.keyR), - 'sub_seek_next': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), - 'sub_seek_prev': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), - 'shader_toggle': HotKey(key: PhysicalKeyboardKey.keyG), - 'skip_marker': HotKey(key: PhysicalKeyboardKey.enter), + 'play_pause': const HotKey(key: PhysicalKeyboardKey.space), + 'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp), + 'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown), + 'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight), + 'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft), + 'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), + 'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + 'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF), + 'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM), + 'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS), + 'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA), + 'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + 'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN), + 'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP), + 'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal), + 'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus), + 'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR), + 'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), + 'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), + 'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG), + 'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter), }; } @@ -1036,6 +1038,15 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyShowUnwatchedCount) ?? true; // Default: enabled (show counts) } + // Hide Spoilers (blur thumbnails and hide descriptions for unwatched episodes) + Future setHideSpoilers(bool enabled) async { + await prefs.setBool(_keyHideSpoilers, enabled); + } + + bool getHideSpoilers() { + return prefs.getBool(_keyHideSpoilers) ?? false; // Default: disabled + } + // Global Shader Preset (for MPV video enhancement) Future setGlobalShaderPreset(String presetId) async { await prefs.setString(_keyGlobalShaderPreset, presetId); @@ -1129,6 +1140,15 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyAmbientLighting) ?? false; } + // Audio Passthrough + Future setAudioPassthrough(bool enabled) async { + await prefs.setBool(_keyAudioPassthrough, enabled); + } + + bool getAudioPassthrough() { + return prefs.getBool(_keyAudioPassthrough) ?? false; + } + // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -1177,6 +1197,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyUseExoPlayer), prefs.remove(_keyAlwaysKeepSidebarOpen), prefs.remove(_keyShowUnwatchedCount), + prefs.remove(_keyHideSpoilers), prefs.remove(_keyGlobalShaderPreset), prefs.remove(_keyRequireProfileSelectionOnOpen), prefs.remove(_keyUseExternalPlayer), @@ -1184,6 +1205,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyCustomExternalPlayers), prefs.remove(_keyConfirmExitOnBack), prefs.remove(_keyAmbientLighting), + prefs.remove(_keyAudioPassthrough), prefs.remove(_keyBufferSizeMigratedToAuto), ]); } diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index b2096888..dffff939 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -95,7 +95,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) { color: c.surface, elevation: 0, margin: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: const BorderRadius.all(Radius.circular(14))), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))), ), inputDecorationTheme: InputDecorationTheme( filled: true, diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index d3dda629..876aa360 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -17,9 +17,8 @@ class CodecUtils { case 'srt': return 'srt'; case 'ass': - return 'ass'; case 'ssa': - return 'ssa'; + return 'ass'; case 'webvtt': case 'vtt': return 'vtt'; diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index 27618325..a17b5481 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -100,4 +100,13 @@ extension PlexMetadataType on PlexMetadata { bool get isClip => _lowerType == ContentTypes.clip; bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); + + /// Whether this episode should have spoiler protection applied. + /// True when the item is an unwatched episode with no active progress. + bool get shouldHideSpoiler { + if (!isEpisode) return false; + if (isWatched) return false; + if (viewOffset != null && viewOffset! > 0) return false; + return true; + } } diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 02bb4efc..fc6a6205 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -115,8 +115,8 @@ Future<({bool confirmed, bool checked})> showConfirmDialogWithCheckbox( /// Shows a delete confirmation dialog. /// Convenience wrapper around [showConfirmDialog] with destructive styling. -Future showDeleteConfirmation(BuildContext context, {required String title, required String message}) { - return showConfirmDialog(context, title: title, message: message, confirmText: t.common.delete, isDestructive: true); +Future showDeleteConfirmation(BuildContext context, {required String title, required String message, String? confirmText}) { + return showConfirmDialog(context, title: title, message: message, confirmText: confirmText ?? t.common.delete, isDestructive: true); } /// Shows a text input dialog for creating/naming items diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index efcd7ac5..40cb1d63 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -185,43 +185,6 @@ String formatFinishTime(Duration remaining, {double rate = 1.0}) { return formatter.format(finishTime); } -/// Formats a DateTime as a relative time string (e.g., "just now", "5m", "3h", "2d", or a full date). -/// Uses the `duration` package for localized unit names. -/// -/// Used for: recent connections timestamps. -String formatRelativeTime(DateTime date) { - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inMinutes < 1) { - return prettyDuration( - Duration.zero, - abbreviated: true, - locale: _getDurationLocale(), - tersity: DurationTersity.minute, - upperTersity: DurationTersity.minute, - ); - } else if (difference.inDays < 7) { - return prettyDuration( - difference, - abbreviated: true, - locale: _getDurationLocale(), - delimiter: ' ', - spacer: '', - tersity: DurationTersity.minute, - upperTersity: () { - if (difference.inDays >= 1) return DurationTersity.day; - if (difference.inHours >= 1) return DurationTersity.hour; - return DurationTersity.minute; - }(), - maxUnits: 1, - ); - } else { - final formatter = DateFormat.yMd(LocaleSettings.currentLocale.languageCode); - return formatter.format(date); - } -} - /// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet String toBulletedString(List parts) { return parts.join(' · '); diff --git a/lib/utils/navigation_transitions.dart b/lib/utils/navigation_transitions.dart new file mode 100644 index 00000000..b729af77 --- /dev/null +++ b/lib/utils/navigation_transitions.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +Route fadeRoute(Widget page) { + return PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child), + transitionDuration: const Duration(milliseconds: 500), + reverseTransitionDuration: const Duration(milliseconds: 500), + ); +} diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart index bbcb5050..a6a9586c 100644 --- a/lib/watch_together/services/watch_together_sync_manager.dart +++ b/lib/watch_together/services/watch_together_sync_manager.dart @@ -54,6 +54,14 @@ class WatchTogetherSyncManager { // Whether the first coordinated play has completed (after this, late joiners catch up via positionSync) bool _firstPlayCompleted = false; + // Clock offset estimation (NTP-style) + // Offset = how far ahead the host's clock is vs ours (in ms) + int _clockOffset = 0; + bool _hasClockOffset = false; + int? _pendingPingTimestamp; + Timer? _clockSyncTimer; + static const Duration _clockSyncInterval = Duration(seconds: 5); + // Track last known state to avoid duplicate broadcasts bool _lastKnownPlaying = false; double _lastKnownRate = 1.0; @@ -116,6 +124,7 @@ class WatchTogetherSyncManager { // popping out of the previous player). if (!_session.isHost) { _peerService.broadcast(SyncMessage.requestSessionConfig(peerId: _peerService.myPeerId)); + _startClockSync(); } appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}'); @@ -150,6 +159,11 @@ class WatchTogetherSyncManager { _hasAnnouncedReady = false; _deferredPlay = false; _deferredPlayPosition = null; + _clockSyncTimer?.cancel(); + _clockSyncTimer = null; + _clockOffset = 0; + _hasClockOffset = false; + _pendingPingTimestamp = null; _playingSubscription?.cancel(); _bufferingSubscription?.cancel(); @@ -249,6 +263,69 @@ class WatchTogetherSyncManager { }); } + /// Start NTP-style clock offset measurement (guest only) + void _startClockSync() { + _clockSyncTimer?.cancel(); + _hasClockOffset = false; + _clockOffset = 0; + _pendingPingTimestamp = null; + + // Initial burst of 3 pings for fast convergence + int burstCount = 0; + Timer.periodic(const Duration(milliseconds: 200), (timer) { + if (burstCount >= 3 || _player == null) { + timer.cancel(); + return; + } + _sendClockPing(); + burstCount++; + }); + + // Then continue at regular interval + _clockSyncTimer = Timer.periodic(_clockSyncInterval, (_) { + if (_player != null) _sendClockPing(); + }); + } + + /// Send a clock-sync ping (guest only) + void _sendClockPing() { + final now = DateTime.now().millisecondsSinceEpoch; + _pendingPingTimestamp = now; + _peerService.broadcast(SyncMessage.ping(now, peerId: _peerService.myPeerId)); + } + + /// Process a clock-sync pong and update clock offset (guest only) + void _processClockPong(SyncMessage message) { + if (_pendingPingTimestamp == null || message.pingId != _pendingPingTimestamp) { + return; // Not our ping, or stale + } + _pendingPingTimestamp = null; + + final t1 = message.pingId!; // Our original send timestamp + final t2 = message.timestamp; // Host's timestamp when it created the pong + final t3 = DateTime.now().millisecondsSinceEpoch; + + final rtt = t3 - t1; + if (rtt < 0 || rtt > 10000) { + appLogger.w('WatchTogether: Discarding clock sample with RTT=${rtt}ms'); + return; + } + + // clockOffset = how far ahead host's clock is relative to ours + final sampleOffset = t2 - t1 - (rtt ~/ 2); + + if (!_hasClockOffset) { + _clockOffset = sampleOffset; + _hasClockOffset = true; + appLogger.d('WatchTogether: Initial clock offset: ${_clockOffset}ms (RTT: ${rtt}ms)'); + } else { + // Exponential moving average + const alpha = 0.3; + _clockOffset = (_clockOffset + (alpha * (sampleOffset - _clockOffset)).round()); + appLogger.d('WatchTogether: Clock offset updated: ${_clockOffset}ms (sample: ${sampleOffset}ms, RTT: ${rtt}ms)'); + } + } + /// Check if this peer can control playback bool _canControl() { if (_session.controlMode == ControlMode.anyone) { @@ -407,12 +484,19 @@ class WatchTogetherSyncManager { case SyncMessageType.ping: if (message.pingId != null) { - _peerService.broadcast(SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId)); + final pong = SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId); + if (message.peerId != null) { + _peerService.sendTo(message.peerId!, pong); + } else { + _peerService.broadcast(pong); + } } break; case SyncMessageType.pong: - // Could be used for latency measurement + if (message.pingId != null && !_session.isHost) { + _processClockPong(message); + } break; case SyncMessageType.mediaSwitch: @@ -522,7 +606,15 @@ class WatchTogetherSyncManager { if (_player == null || _session.isHost) return; final localPosition = _player!.state.position; - final networkDelay = DateTime.now().millisecondsSinceEpoch - remoteTimestamp; + final now = DateTime.now().millisecondsSinceEpoch; + + // Translate host's timestamp to our local time frame using clock offset + // _clockOffset = hostClock - localClock, so localEquivalent = remoteTimestamp - _clockOffset + final adjustedRemoteTimestamp = remoteTimestamp - _clockOffset; + final rawDelay = now - adjustedRemoteTimestamp; + + // Before clock offset is available, use 0 (compare positions directly) + final networkDelay = _hasClockOffset ? rawDelay.clamp(0, 5000) : 0; // Estimate where remote should be now, accounting for playback time elapsed Duration estimatedRemoteNow = remotePosition; @@ -682,6 +774,7 @@ class WatchTogetherSyncManager { /// Dispose resources void dispose() { + _clockSyncTimer?.cancel(); detachPlayer(); _peerReady.clear(); _hasAnnouncedReady = false; diff --git a/lib/widgets/artwork_picker_dialog.dart b/lib/widgets/artwork_picker_dialog.dart index a93ce810..6ede9cb6 100644 --- a/lib/widgets/artwork_picker_dialog.dart +++ b/lib/widgets/artwork_picker_dialog.dart @@ -187,37 +187,40 @@ class _ArtworkPickerDialogState extends State { return FocusableWrapper( borderRadius: 8, onSelect: () => _selectArtwork(artwork), - child: Stack( - fit: StackFit.expand, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: PlexOptimizedImage( - client: widget.client, - imagePath: thumbUrl, - fit: BoxFit.contain, + child: GestureDetector( + onTap: () => _selectArtwork(artwork), + child: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - ), - ), - if (isSelected) - Positioned( - right: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: PlexOptimizedImage( + client: widget.client, + imagePath: thumbUrl, + fit: BoxFit.contain, ), - child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), ), ), - ], + if (isSelected) + Positioned( + right: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), + ), + ), + ], + ), ), ); }, diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index d0228570..985b675a 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -48,6 +48,12 @@ class FocusableListTile extends StatefulWidget { /// An optional color to display behind the menu item when being hovered. final Color? hoverColor; + /// An optional color for the text of the list tile. + final Color? textColor; + + /// An optional color for the icon of the list tile. + final Color? iconColor; + const FocusableListTile({ super.key, this.title, @@ -64,6 +70,8 @@ class FocusableListTile extends StatefulWidget { this.contentPadding, this.suppressInitialSelect = false, this.hoverColor, + this.textColor, + this.iconColor, }); @override @@ -89,6 +97,8 @@ class _FocusableListTileState extends State { focusNode: widget.suppressInitialSelect ? null : widget.focusNode, autofocus: widget.suppressInitialSelect ? false : widget.autofocus, hoverColor: widget.hoverColor, + textColor: widget.textColor, + iconColor: widget.iconColor, ); if (!widget.suppressInitialSelect) { diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index d8a7df17..11c3a54b 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:plezy/utils/content_utils.dart'; import 'package:plezy/widgets/app_icon.dart'; @@ -557,8 +559,8 @@ class _MediaCardList extends StatelessWidget { ), const SizedBox(height: 4), ], - // Summary - if (item.summary != null) ...[ + // Summary (hidden when spoiler protection is active) + if (!(item is PlexMetadata && context.watch().hideSpoilers && (item as PlexMetadata).shouldHideSpoiler) && item.summary != null) ...[ Text( item.summary!, maxLines: _summaryMaxLines, @@ -605,12 +607,25 @@ Widget _buildPosterImage( localFilePath: localPosterPath, ); } else if (item is PlexMetadata) { - final episodePosterMode = context.watch().episodePosterMode; + final settingsProvider = context.watch(); + final episodePosterMode = settingsProvider.episodePosterMode; + final shouldBlur = settingsProvider.hideSpoilers && item.shouldHideSpoiler; posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); + Widget image; + // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { - return PlexOptimizedImage.thumb( + image = PlexOptimizedImage.thumb( + client: isOffline ? null : context.getClientWithFallback(item.serverId), + imagePath: posterUrl, + width: knownWidth ?? double.infinity, + height: knownHeight ?? double.infinity, + fit: BoxFit.cover, + localFilePath: localPosterPath, + ); + } else { + image = PlexOptimizedImage.poster( client: isOffline ? null : context.getClientWithFallback(item.serverId), imagePath: posterUrl, width: knownWidth ?? double.infinity, @@ -620,14 +635,15 @@ Widget _buildPosterImage( ); } - return PlexOptimizedImage.poster( - client: isOffline ? null : context.getClientWithFallback(item.serverId), - imagePath: posterUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - localFilePath: localPosterPath, - ); + if (shouldBlur) { + return ClipRect( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: image, + ), + ); + } + return image; } return SkeletonLoader( @@ -828,7 +844,7 @@ class _SkeletonLoaderState extends State with SingleTickerProvid identifier: "skeleton-loader", child: Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: _animation.value), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: _animation.value * 0.15), borderRadius: widget.borderRadius ?? BorderRadius.circular(tokens(context).radiusSm), ), child: widget.child, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index be75d9e2..3077bc43 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -37,8 +37,9 @@ class _MenuAction { final IconData icon; final String label; final Color? hoverColor; + final Color? foregroundColor; - _MenuAction({required this.value, required this.icon, required this.label, this.hoverColor}); + _MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor}); } /// A reusable wrapper widget that adds a context menu (long press / right click) @@ -284,9 +285,10 @@ class MediaContextMenuState extends State { menuActions.add( _MenuAction( value: 'delete_media', - icon: Symbols.delete_rounded, - label: t.common.delete, + icon: Symbols.delete_forever_rounded, + label: t.mediaMenu.deleteFromServer, hoverColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.error, ), ); } @@ -1137,8 +1139,9 @@ class MediaContextMenuState extends State { // Show confirmation dialog final confirmed = await showDeleteConfirmation( context, - title: t.common.delete, - message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n${t.mediaMenu.deleteMultipleWarning}" : ""}", + title: t.mediaMenu.deleteFromServer, + message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n\n${t.mediaMenu.deleteMultipleWarning}" : ""}", + confirmText: t.mediaMenu.deleteFromServer, ); if (!confirmed || !context.mounted) return; @@ -1330,6 +1333,8 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet> title: Text(action.label), onTap: () => OverlaySheetController.closeAdaptive(context, action.value), hoverColor: action.hoverColor, + textColor: action.foregroundColor, + iconColor: action.foregroundColor, ); }), ], @@ -1435,6 +1440,8 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { title: Text(action.label), onTap: () => Navigator.pop(context, action.value), hoverColor: action.hoverColor, + textColor: action.foregroundColor, + iconColor: action.foregroundColor, ); }).toList(), ), diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 5c4abb48..b0f718c5 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -48,14 +48,18 @@ class OverlaySheetController { /// Whether a sheet is currently showing (including while animating closed). bool get isOpen => _state._isOpen; - /// Show a bottom sheet with [builder] content. Returns a Future that completes + /// Show a sheet with [builder] content. Returns a Future that completes /// when the sheet is closed (with an optional result). + /// + /// [alignment] controls where the sheet appears. Defaults to + /// [Alignment.bottomCenter]. Use [Alignment.topCenter] to anchor at the top. Future show({ required WidgetBuilder builder, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, FocusNode? initialFocusNode, + Alignment alignment = Alignment.bottomCenter, }) { return _state._show( builder: builder, @@ -63,6 +67,7 @@ class OverlaySheetController { backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, initialFocusNode: initialFocusNode, + alignment: alignment, ); } @@ -98,6 +103,7 @@ class OverlaySheetController { bool barrierDismissible = true, bool isScrollControlled = false, FocusNode? initialFocusNode, + Alignment alignment = Alignment.bottomCenter, }) { final controller = maybeOf(context); if (controller != null) { @@ -107,6 +113,7 @@ class OverlaySheetController { backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, initialFocusNode: initialFocusNode, + alignment: alignment, ); } return showModalBottomSheet( @@ -176,7 +183,7 @@ class OverlaySheetHost extends StatefulWidget { class _OverlaySheetHostState extends State with SingleTickerProviderStateMixin { late final AnimationController _animationController; - late final Animation _slideAnimation; + late final CurvedAnimation _slideCurve; late final Animation _barrierAnimation; late final OverlaySheetController _controller; @@ -188,6 +195,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr bool _barrierDismissible = true; BoxConstraints? _constraints; Color? _explicitBackgroundColor; + Alignment _alignment = Alignment.bottomCenter; // Drag-to-dismiss state double _dragOffset = 0; @@ -200,8 +208,10 @@ class _OverlaySheetHostState extends State with SingleTickerPr _animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this); - _slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic), + _slideCurve = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, ); _barrierAnimation = Tween( @@ -218,6 +228,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr } } _sheetFocusScopeNode.dispose(); + _slideCurve.dispose(); _animationController.dispose(); super.dispose(); } @@ -228,6 +239,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr Color? backgroundColor, bool barrierDismissible = true, FocusNode? initialFocusNode, + Alignment alignment = Alignment.bottomCenter, }) { // If already open, close first (instant) if (_isOpen) { @@ -250,6 +262,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr _barrierDismissible = barrierDismissible; _constraints = constraints; _explicitBackgroundColor = backgroundColor; + _alignment = alignment; _dragOffset = 0; _isDragging = false; }); @@ -448,29 +461,44 @@ class _OverlaySheetHostState extends State with SingleTickerPr Widget _buildSheet(BuildContext context) { final size = MediaQuery.of(context).size; final isDesktop = size.width > 600; + final isTop = _alignment.y < 0; final effectiveConstraints = _constraints ?? BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75); + // Slide direction depends on alignment: bottom sheets slide up, top sheets slide down. + final slideBegin = isTop ? const Offset(0, -1) : const Offset(0, 1); + final borderRadius = isTop + ? const BorderRadius.vertical(bottom: Radius.circular(16)) + : const BorderRadius.vertical(top: Radius.circular(16)); + Widget sheet = FocusScope( node: _sheetFocusScopeNode, child: Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: _handleKeyEvent, - child: SlideTransition( - position: _slideAnimation, + child: AnimatedBuilder( + animation: _slideCurve, + builder: (context, child) { + final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!; + return FractionalTranslation( + translation: slideOffset, + child: child, + ); + }, child: Align( - alignment: Alignment.bottomCenter, + alignment: _alignment, child: Transform.translate( offset: Offset(0, _dragOffset.clamp(0, double.infinity)), child: Material( color: _explicitBackgroundColor ?? Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: SafeArea( - top: false, + top: !isTop, + bottom: isTop, child: ConstrainedBox( constraints: effectiveConstraints, child: _pageStack.isNotEmpty ? _pageStack.last.builder(context) : const SizedBox.shrink(), @@ -483,8 +511,8 @@ class _OverlaySheetHostState extends State with SingleTickerPr ), ); - // Swipe-down-to-dismiss (skip on TV where there's no touchscreen) - if (!PlatformDetector.isTV()) { + // Swipe-down-to-dismiss (skip on TV and for top-aligned sheets) + if (!PlatformDetector.isTV() && !isTop) { sheet = GestureDetector( onVerticalDragStart: (_) { _isDragging = true; diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 1d51c60d..b8d678e6 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import '../services/image_cache_service.dart'; import '../../services/plex_client.dart'; import '../utils/plex_image_helper.dart'; import 'media_card.dart'; @@ -338,6 +339,7 @@ class PlexOptimizedImage extends StatelessWidget { image: CachedNetworkImageProvider( imageUrl, cacheKey: effectiveCacheKey, + cacheManager: PlexImageCacheManager.instance, headers: const {'User-Agent': 'Plezy'}, maxHeight: memHeight, ), diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 9f306562..7b905984 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -68,6 +68,7 @@ class DesktopVideoControls extends StatefulWidget { final VoidCallback? onLoadSeekTimes; final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; + final void Function(String propertyName, int offset)? onSyncOffsetChanged; final String serverId; final VoidCallback? onBack; @@ -80,8 +81,8 @@ class DesktopVideoControls extends StatefulWidget { final ShaderService? shaderService; final VoidCallback? onShaderChanged; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; @@ -138,13 +139,14 @@ class DesktopVideoControls extends StatefulWidget { this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, + this.onSyncOffsetChanged, this.serverId = '', this.onBack, this.canControl = true, this.hasFirstFrame, this.shaderService, this.onShaderChanged, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.isAmbientLightingEnabled = false, @@ -451,7 +453,7 @@ class DesktopVideoControlsState extends State { const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: Colors.red, borderRadius: const BorderRadius.all(Radius.circular(4))), + decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))), child: Text( t.liveTv.live, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), @@ -484,7 +486,7 @@ class DesktopVideoControlsState extends State { onKeyEvent: _handleTimelineKeyEvent, onFocusChange: _onFocusChange, enabled: canInteract, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, ), const SizedBox(height: 4), ], @@ -689,6 +691,7 @@ class DesktopVideoControlsState extends State { onLoadSeekTimes: widget.onLoadSeekTimes, onCancelAutoHide: widget.onCancelAutoHide, onStartAutoHide: widget.onStartAutoHide, + onSyncOffsetChanged: widget.onSyncOffsetChanged, focusNodes: _trackControlFocusNodes, onFocusChange: _onFocusChange, onNavigateLeft: navigateFromTrackToVolume, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index a98e6fff..f59584cd 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -43,8 +45,8 @@ class MobileVideoControls extends StatelessWidget { /// Notifier for whether first video frame has rendered (shows loading state when false). final ValueNotifier? hasFirstFrame; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream final bool isLive; @@ -73,7 +75,7 @@ class MobileVideoControls extends StatelessWidget { this.onSeekToNextChapter, this.canControl = true, this.hasFirstFrame, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, }); @@ -197,7 +199,7 @@ class MobileVideoControls extends StatelessWidget { children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: Colors.red, borderRadius: const BorderRadius.all(Radius.circular(4))), + decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))), child: Text( t.liveTv.live, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), @@ -225,7 +227,7 @@ class MobileVideoControls extends StatelessWidget { horizontalLayout: false, enabled: canControl, showFinishTime: true, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, ), ), ); diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 8b6edbf9..88556232 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -35,7 +35,6 @@ class ChapterSheet extends StatefulWidget { } class _ChapterSheetState extends State { - /// Get the PlexClient for chapters, or null if unavailable (offline mode) PlexClient? _tryGetClientForChapters(BuildContext context) { if (widget.serverId == null) return null; @@ -49,55 +48,53 @@ class _ChapterSheetState extends State { @override Widget build(BuildContext context) { return StreamBuilder( - stream: widget.player.streams.position, - initialData: widget.player.state.position, - builder: (context, positionSnapshot) { - final currentPosition = positionSnapshot.data ?? Duration.zero; - final currentPositionMs = currentPosition.inMilliseconds; + stream: widget.player.streams.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + final currentPosition = positionSnapshot.data ?? Duration.zero; + final currentPositionMs = currentPosition.inMilliseconds; - // Find the current chapter based on position - int? currentChapterIndex; - for (int i = 0; i < widget.chapters.length; i++) { - final chapter = widget.chapters[i]; - final startMs = chapter.startTimeOffset ?? 0; - final endMs = - chapter.endTimeOffset ?? - (i < widget.chapters.length - 1 - ? widget.chapters[i + 1].startTimeOffset ?? 0 - : double.maxFinite.toInt()); + // Find the current chapter based on position + int? currentChapterIndex; + for (int i = 0; i < widget.chapters.length; i++) { + final chapter = widget.chapters[i]; + final startMs = chapter.startTimeOffset ?? 0; + final endMs = + chapter.endTimeOffset ?? + (i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt()); - if (currentPositionMs >= startMs && currentPositionMs < endMs) { - currentChapterIndex = i; - break; - } + if (currentPositionMs >= startMs && currentPositionMs < endMs) { + currentChapterIndex = i; + break; } + } - Widget content; - if (!widget.chaptersLoaded) { - content = const Center(child: CircularProgressIndicator()); - } else if (widget.chapters.isEmpty) { - content = Center( - child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)), - ); - } else { - content = ListView.builder( - itemCount: widget.chapters.length, - itemBuilder: (context, index) { - final chapter = widget.chapters[index]; - final isCurrentChapter = currentChapterIndex == index; + Widget content; + if (!widget.chaptersLoaded) { + content = const Center(child: CircularProgressIndicator()); + } else if (widget.chapters.isEmpty) { + content = Center( + child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)), + ); + } else { + content = ListView.builder( + itemCount: widget.chapters.length, + itemBuilder: (context, index) { + final chapter = widget.chapters[index]; + final isCurrentChapter = currentChapterIndex == index; - // Get local file path for offline chapter thumbnails - final localThumbPath = widget.serverId != null && chapter.thumb != null - ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) - : null; + // Get local file path for offline chapter thumbnails + final localThumbPath = widget.serverId != null && chapter.thumb != null + ? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!) + : null; - return FocusableListTile( - leading: chapter.thumb != null - ? SizedBox( - width: 60, - height: 34, - child: Stack( - children: [ + return FocusableListTile( + leading: chapter.thumb != null + ? SizedBox( + width: 60, + height: 34, + child: Stack( + children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: PlexOptimizedImage.thumb( @@ -114,48 +111,48 @@ class _ChapterSheetState extends State { if (isCurrentChapter) Positioned.fill( child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(4)), + border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), ), ), ), - ], - ), - ) - : null, - title: Text( - chapter.label, - style: TextStyle( - color: isCurrentChapter ? Colors.blue : null, - fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal, - ), + ], + ), + ) + : null, + title: Text( + chapter.label, + style: TextStyle( + color: isCurrentChapter ? Colors.blue : null, + fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal, ), - subtitle: Text( - formatDurationTimestamp(chapter.startTime), - style: TextStyle( - color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, - fontSize: 12, - ), + ), + subtitle: Text( + formatDurationTimestamp(chapter.startTime), + style: TextStyle( + color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, + fontSize: 12, ), - trailing: isCurrentChapter - ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) - : null, - onTap: () { - widget.player.seek(chapter.startTime); - OverlaySheetController.of(context).close(); - }, - ); - }, - ); - } - - return BaseVideoControlSheet( - title: t.videoControls.chapters, - icon: Symbols.video_library_rounded, - child: content, + ), + trailing: isCurrentChapter + ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) + : null, + onTap: () { + widget.player.seek(chapter.startTime); + OverlaySheetController.of(context).close(); + }, + ); + }, ); - }, - ); + } + + return BaseVideoControlSheet( + title: t.videoControls.chapters, + icon: Symbols.video_library_rounded, + child: content, + ); + }, + ); } } diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index 4a3a2228..ec65b752 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -62,9 +62,7 @@ class QueueSheet extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - trailing: isCurrent - ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) - : null, + trailing: isCurrent ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) : null, onTap: () { onItemSelected(item); OverlaySheetController.of(context).close(); @@ -74,11 +72,7 @@ class QueueSheet extends StatelessWidget { ); } - return BaseVideoControlSheet( - title: t.videoControls.queue, - icon: Symbols.queue_music_rounded, - child: content, - ); + return BaseVideoControlSheet(title: t.videoControls.queue, icon: Symbols.queue_music_rounded, child: content); }, ); } @@ -109,9 +103,9 @@ class QueueSheet extends StatelessWidget { if (isCurrent) Positioned.fill( child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(4)), + border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), ), ), ), diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 9b854ece..af2140c9 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -12,6 +12,7 @@ import '../../../providers/shader_provider.dart'; import '../../../services/settings_service.dart'; import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; +import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; import '../../../utils/platform_detector.dart'; import '../../../theme/mono_tokens.dart'; @@ -91,6 +92,15 @@ class VideoSettingsSheet extends StatefulWidget { /// Called to toggle ambient lighting on/off (null if unsupported) final VoidCallback? onToggleAmbientLighting; + /// Called to cancel the video controls auto-hide timer. + final VoidCallback? onCancelAutoHide; + + /// Called to restart the video controls auto-hide timer. + final VoidCallback? onStartAutoHide; + + /// Called when a sync offset changes (so the parent can update its state). + final void Function(String propertyName, int offset)? onSyncOffsetChanged; + const VideoSettingsSheet({ super.key, required this.player, @@ -102,6 +112,9 @@ class VideoSettingsSheet extends StatefulWidget { this.onShaderChanged, this.isAmbientLightingEnabled = false, this.onToggleAmbientLighting, + this.onCancelAutoHide, + this.onStartAutoHide, + this.onSyncOffsetChanged, }); @override @@ -115,6 +128,7 @@ class _VideoSettingsSheetState extends State { bool _enableHDR = true; bool _showPerformanceOverlay = false; bool _autoPlayNextEpisode = true; + bool _audioPassthrough = false; @override void initState() { @@ -131,6 +145,7 @@ class _VideoSettingsSheetState extends State { _enableHDR = settings.getEnableHDR(); _showPerformanceOverlay = settings.getShowPerformanceOverlay(); _autoPlayNextEpisode = settings.getAutoPlayNextEpisode(); + _audioPassthrough = settings.getAudioPassthrough(); }); } @@ -166,13 +181,77 @@ class _VideoSettingsSheetState extends State { }); } + Future _toggleAudioPassthrough() async { + final newValue = !_audioPassthrough; + final settings = await SettingsService.getInstance(); + await settings.setAudioPassthrough(newValue); + if (!mounted) return; + setState(() { + _audioPassthrough = newValue; + }); + await widget.player.setAudioPassthrough(newValue); + } + void _navigateTo(_SettingsView view) { + // Sync views open as a compact top bar instead of a sub-view + if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) { + _openSyncBar(view); + return; + } setState(() { _currentView = view; }); OverlaySheetController.maybeOf(context)?.refocus(); } + void _openSyncBar(_SettingsView view) { + final controller = OverlaySheetController.maybeOf(context); + if (controller == null) return; + + final isSubtitle = view == _SettingsView.subtitleSync; + final title = isSubtitle ? t.videoSettings.subtitleSync : t.videoSettings.audioSync; + final icon = isSubtitle ? Symbols.subtitles_rounded : Symbols.sync_rounded; + final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay'; + final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset; + + // Created here so we can pass it as initialFocusNode to the overlay sheet, + // ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar. + final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider'); + + // show() with new alignment replaces the current sheet (completing the + // settings sheet future, which restarts the auto-hide timer via + // whenComplete in track_chapter_controls). Cancel it again here. + controller.show( + alignment: Alignment.topCenter, + constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900), + initialFocusNode: sliderFocusNode, + builder: (_) => _CompactSyncBar( + title: title, + icon: icon, + player: widget.player, + propertyName: propertyName, + initialOffset: initialOffset, + sliderFocusNode: sliderFocusNode, + onOffsetChanged: (offset) async { + final settings = await SettingsService.getInstance(); + if (isSubtitle) { + await settings.setSubtitleSyncOffset(offset); + } else { + await settings.setAudioSyncOffset(offset); + } + widget.onSyncOffsetChanged?.call(propertyName, offset); + }, + ), + ).whenComplete(() { + widget.onStartAutoHide?.call(); + }); + + // Cancel auto-hide after show() — the previous sheet's whenComplete + // fires as a microtask and restarts the timer, so schedule our cancel + // to run after that microtask. + Future.microtask(() => widget.onCancelAutoHide?.call()); + } + void _navigateBack() { setState(() { _currentView = _SettingsView.menu; @@ -331,6 +410,23 @@ class _VideoSettingsSheetState extends State { }, ), + // Audio Passthrough (Desktop only) + if (isDesktop) + ListTile( + leading: AppIcon( + Symbols.surround_sound_rounded, + fill: 1, + color: _audioPassthrough ? Colors.amber : tokens(context).textMuted, + ), + title: Text(t.videoSettings.audioPassthrough), + trailing: Switch( + value: _audioPassthrough, + onChanged: (_) => _toggleAudioPassthrough(), + activeThumbColor: Colors.amber, + ), + onTap: _toggleAudioPassthrough, + ), + // Shader Preset (MPV only) if (widget.shaderService != null && widget.shaderService!.isSupported) _SettingsMenuItem( @@ -423,39 +519,7 @@ class _VideoSettingsSheetState extends State { return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close()); } - Widget _buildAudioSyncView() { - return SyncOffsetControl( - player: widget.player, - propertyName: 'audio-delay', - initialOffset: _audioSyncOffset, - labelText: t.videoControls.audioLabel, - onOffsetChanged: (offset) async { - final settings = await SettingsService.getInstance(); - await settings.setAudioSyncOffset(offset); - if (!mounted) return; - setState(() { - _audioSyncOffset = offset; - }); - }, - ); - } - - Widget _buildSubtitleSyncView() { - return SyncOffsetControl( - player: widget.player, - propertyName: 'sub-delay', - initialOffset: _subtitleSyncOffset, - labelText: t.videoControls.subtitlesLabel, - onOffsetChanged: (offset) async { - final settings = await SettingsService.getInstance(); - await settings.setSubtitleSyncOffset(offset); - if (!mounted) return; - setState(() { - _subtitleSyncOffset = offset; - }); - }, - ); - } + // Audio/subtitle sync views are now opened as compact top bars via _openSyncBar() /// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn"). static String _audioBackend(String name) { @@ -638,9 +702,8 @@ class _VideoSettingsSheetState extends State { case _SettingsView.sleep: return _buildSleepView(); case _SettingsView.audioSync: - return _buildAudioSyncView(); case _SettingsView.subtitleSync: - return _buildSubtitleSyncView(); + return _buildMenuView(); // Sync views open as top bars, fallback to menu case _SettingsView.audioDevice: return _buildAudioDeviceView(); case _SettingsView.shader: @@ -650,3 +713,84 @@ class _VideoSettingsSheetState extends State { ); } } + +/// Compact sync bar shown at the top of the screen so subtitles remain visible. +class _CompactSyncBar extends StatefulWidget { + final String title; + final IconData icon; + final Player player; + final String propertyName; + final int initialOffset; + final Future Function(int offset) onOffsetChanged; + final FocusNode sliderFocusNode; + + const _CompactSyncBar({ + required this.title, + required this.icon, + required this.player, + required this.propertyName, + required this.initialOffset, + required this.onOffsetChanged, + required this.sliderFocusNode, + }); + + @override + State<_CompactSyncBar> createState() => _CompactSyncBarState(); +} + +class _CompactSyncBarState extends State<_CompactSyncBar> { + final _resetFocusNode = FocusNode(debugLabel: 'SyncResetButton'); + final _closeFocusNode = FocusNode(debugLabel: 'SyncCloseButton'); + + @override + void dispose() { + widget.sliderFocusNode.dispose(); + _resetFocusNode.dispose(); + _closeFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const SizedBox(width: 16), + AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20), + const SizedBox(width: 8), + Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + Expanded( + child: SyncOffsetControl( + player: widget.player, + propertyName: widget.propertyName, + initialOffset: widget.initialOffset, + labelText: widget.title, + onOffsetChanged: widget.onOffsetChanged, + compact: true, + sliderFocusNode: widget.sliderFocusNode, + resetFocusNode: _resetFocusNode, + closeFocusNode: _closeFocusNode, + ), + ), + const SizedBox(width: 8), + FocusableWrapper( + focusNode: _closeFocusNode, + onSelect: () => OverlaySheetController.of(context).close(), + onNavigateLeft: () => _resetFocusNode.requestFocus(), + borderRadius: 18, + autoScroll: false, + useBackgroundFocus: true, + child: GestureDetector( + onTap: () => OverlaySheetController.of(context).close(), + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22), + ), + ), + ), + const SizedBox(width: 12), + ], + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d9017c55..8bab6f36 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,5 +1,6 @@ import 'dart:async' show StreamSubscription, Timer; import 'dart:io' show Platform; +import 'dart:typed_data'; import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent; import 'package:flutter/material.dart'; @@ -14,6 +15,7 @@ import 'package:flutter/services.dart' PhysicalKeyboardKey, KeyEvent, KeyDownEvent, + KeyUpEvent, HardwareKeyboard; import '../../services/fullscreen_state_manager.dart'; import '../../services/macos_window_service.dart'; @@ -76,7 +78,7 @@ Widget plexVideoControlsBuilder( ValueNotifier? controlsVisible, ShaderService? shaderService, VoidCallback? onShaderChanged, - String Function(Duration time)? thumbnailUrlBuilder, + Uint8List? Function(Duration time)? thumbnailDataBuilder, bool isLive = false, String? liveChannelName, bool isAmbientLightingEnabled = false, @@ -102,7 +104,7 @@ Widget plexVideoControlsBuilder( controlsVisible: controlsVisible, shaderService: shaderService, onShaderChanged: onShaderChanged, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, isLive: isLive, liveChannelName: liveChannelName, isAmbientLightingEnabled: isAmbientLightingEnabled, @@ -147,8 +149,8 @@ class PlexVideoControls extends StatefulWidget { /// Called when shader preset changes final VoidCallback? onShaderChanged; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; /// Whether this is a live TV stream (disables seek, progress, etc.) final bool isLive; @@ -183,7 +185,7 @@ class PlexVideoControls extends StatefulWidget { this.controlsVisible, this.shaderService, this.onShaderChanged, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.isAmbientLightingEnabled = false, @@ -244,6 +246,9 @@ class _PlexVideoControlsState extends State with WindowListen int _autoSkipDelay = 5; Timer? _autoSkipTimer; double _autoSkipProgress = 0.0; + // Skip button dismiss state + bool _skipButtonDismissed = false; + Timer? _skipButtonDismissTimer; // Video player navigation (use arrow keys to navigate controls) bool _videoPlayerNavigationEnabled = false; // Performance overlay @@ -353,15 +358,23 @@ class _PlexVideoControlsState extends State with WindowListen void _updateCurrentMarker(PlexMarker? foundMarker) { setState(() { _currentMarker = foundMarker; + _skipButtonDismissed = false; }); if (foundMarker == null) { _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); return; } _startAutoSkipTimer(foundMarker); + // Auto-skip OFF: dismiss button after 7s if no interaction + // Auto-skip ON: button stays until controls hide + if (!_shouldAutoSkipForMarker(foundMarker)) { + _startSkipButtonDismissTimer(); + } + // Auto-focus skip button on TV when marker appears (only in keyboard/TV mode, if controls hidden) if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -405,10 +418,14 @@ class _PlexVideoControlsState extends State with WindowListen void _skipMarker() { if (_currentMarker != null) { final endTime = _currentMarker!.endTime; + setState(() { + _currentMarker = null; + }); widget.player.seek(endTime); widget.onSeekCompleted?.call(endTime); } _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); } void _startAutoSkipTimer(PlexMarker marker) { @@ -455,6 +472,24 @@ class _PlexVideoControlsState extends State with WindowListen } } + /// Starts/restarts the skip button dismiss timer. When it fires, hides the + /// button and cancels any active auto-skip countdown. + void _startSkipButtonDismissTimer() { + _skipButtonDismissTimer?.cancel(); + _skipButtonDismissTimer = Timer(const Duration(seconds: 7), () { + if (!mounted || _currentMarker == null) return; + setState(() { + _skipButtonDismissed = true; + }); + _cancelAutoSkipTimer(); + }); + } + + void _cancelSkipButtonDismissTimer() { + _skipButtonDismissTimer?.cancel(); + _skipButtonDismissTimer = null; + } + /// Perform the appropriate skip action based on marker type and next episode availability void _performAutoSkip() { if (_currentMarker == null) return; @@ -471,9 +506,13 @@ class _PlexVideoControlsState extends State with WindowListen } /// Check if auto-skip should be active for the current marker + bool _shouldAutoSkipForMarker(PlexMarker marker) { + return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); + } + bool _shouldShowAutoSkip() { if (_currentMarker == null) return false; - return (_currentMarker!.isCredits && _autoSkipCredits) || (!_currentMarker!.isCredits && _autoSkipIntro); + return _shouldAutoSkipForMarker(_currentMarker!); } Future _loadSeekTimes() async { @@ -565,6 +604,7 @@ class _PlexVideoControlsState extends State with WindowListen _hideTimer?.cancel(); _feedbackTimer?.cancel(); _autoSkipTimer?.cancel(); + _skipButtonDismissTimer?.cancel(); _singleTapTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); @@ -648,21 +688,30 @@ class _PlexVideoControlsState extends State with WindowListen if (!mounted || !_showControls) return; setState(() { _showControls = false; + // Dismiss skip button with controls — after this it only re-appears with controls + if (_currentMarker != null) { + _skipButtonDismissed = true; + } }); + _cancelSkipButtonDismissTimer(); widget.controlsVisible?.value = false; if (Platform.isMacOS) { _updateTrafficLightVisibility(); } - // Immediately try to reclaim focus (important for TV where global handler - // won't fire if _focusNode lost focus) - if (!_focusNode.hasFocus) { - _focusNode.requestFocus(); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_focusNode.hasFocus) { + // Reclaim focus so the global key handler stays active for TV dpad, + // but skip if an overlay sheet owns focus — stealing it would break + // sheet navigation (e.g. the compact sync bar). + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (!sheetOpen) { + if (!_focusNode.hasFocus) { _focusNode.requestFocus(); } - }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_focusNode.hasFocus) { + _focusNode.requestFocus(); + } + }); + } } void _startHideTimer() { @@ -742,21 +791,20 @@ class _PlexVideoControlsState extends State with WindowListen } void _toggleControls() { - setState(() { - _showControls = !_showControls; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = _showControls; - // Cancel auto-skip on any tap, not just when controls become visible - _cancelAutoSkipTimer(); if (_showControls) { + _hideControls(); + } else { + setState(() { + _showControls = true; + }); + widget.controlsVisible?.value = true; _startHideTimer(); + if (Platform.isMacOS) { + _updateTrafficLightVisibility(); + } } - - // On macOS, hide/show traffic lights with controls - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } + // Cancel auto-skip on any tap + _cancelAutoSkipTimer(); } void _toggleRotationLock() async { @@ -918,6 +966,15 @@ class _PlexVideoControlsState extends State with WindowListen }, onCancelAutoHide: () => _hideTimer?.cancel(), onStartAutoHide: _startHideTimer, + onSyncOffsetChanged: (propertyName, offset) { + setState(() { + if (propertyName == 'sub-delay') { + _subtitleSyncOffset = offset; + } else { + _audioSyncOffset = offset; + } + }); + }, serverId: widget.metadata.serverId ?? '', canControl: widget.canControl, isLive: widget.isLive, @@ -1270,7 +1327,7 @@ class _PlexVideoControlsState extends State with WindowListen child: Row( mainAxisSize: MainAxisSize.min, children: [ - AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16), + const AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16), const SizedBox(width: 4), const Text( '2x', @@ -1288,6 +1345,14 @@ class _PlexVideoControlsState extends State with WindowListen } } + /// Exit fullscreen if the window is actually fullscreen (async check). + /// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag. + Future _exitFullscreenIfNeeded() async { + if (await windowManager.isFullScreen()) { + await FullscreenStateManager().exitFullscreen(); + } + } + /// Initialize always-on-top state from window manager (desktop only) Future _initAlwaysOnTopState() async { final isOnTop = await windowManager.isAlwaysOnTop(); @@ -1370,8 +1435,9 @@ class _PlexVideoControlsState extends State with WindowListen bool _handleGlobalKeyEvent(KeyEvent event) { if (!mounted) return false; - // TV back key fallback — Focus.onKeyEvent won't fire if _focusNode lost focus - if (PlatformDetector.isTV() && event.logicalKey.isBackKey) { + // Back key fallback when _focusNode lost focus (TV, or desktop with nav on). + // Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here. + if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) { if (!_focusNode.hasFocus) { // Skip if an overlay sheet is open — the sheet's FocusScope handles // back keys via its own onKeyEvent. Without this check, this global @@ -1407,6 +1473,20 @@ class _PlexVideoControlsState extends State with WindowListen // (e.g. after controls auto-hide). The !hasFocus guard prevents // double-handling when the Focus onKeyEvent already processes the event. if (!_focusNode.hasFocus && _keyboardService != null) { + // On Windows/Linux with navigation off, ESC only exits fullscreen — + // never exits the player. Intercept before the keyboard shortcuts + // service which would call onBack and pop the route. + // Skip if an overlay sheet is open — let the sheet handle ESC. + if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) { + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (!sheetOpen) { + if (event is KeyUpEvent) { + _exitFullscreenIfNeeded(); + } + _focusNode.requestFocus(); + return true; + } + } final result = _keyboardService!.handleVideoPlayerKeyEvent( event, widget.player, @@ -1494,16 +1574,7 @@ class _PlexVideoControlsState extends State with WindowListen } if (_showControls) { - setState(() { - _showControls = false; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = false; - // Return focus to the main focus node - _focusNode.requestFocus(); - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } + _hideControls(); } } @@ -1521,12 +1592,18 @@ class _PlexVideoControlsState extends State with WindowListen focusNode: _focusNode, autofocus: true, onKeyEvent: (node, event) { - final backResult = handleBackKeyAction(event, () { - // On Windows/Linux with navigation off, ESC first exits fullscreen - if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) { - _toggleFullscreen(); - return; + // On Windows/Linux with navigation off, ESC only exits fullscreen — + // never exits the player. Consume all back key events and check + // actual window state asynchronously. + if (!_videoPlayerNavigationEnabled && + (Platform.isWindows || Platform.isLinux) && + event.logicalKey.isBackKey) { + if (event is KeyUpEvent) { + _exitFullscreenIfNeeded(); } + return KeyEventResult.handled; + } + final backResult = handleBackKeyAction(event, () { if (!_showControls) { _showControlsWithFocus(); return; @@ -1809,7 +1886,7 @@ class _PlexVideoControlsState extends State with WindowListen onSeekToNextChapter: _seekToNextChapter, canControl: widget.canControl, hasFirstFrame: widget.hasFirstFrame, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, isLive: widget.isLive, liveChannelName: widget.liveChannelName, ), @@ -1836,8 +1913,8 @@ class _PlexVideoControlsState extends State with WindowListen ), // Speed indicator overlay for long-press 2x if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())), - // Skip intro/credits button - if (_currentMarker != null) + // Skip intro/credits button (auto-dismisses after 7s, then only shows with controls) + if (_currentMarker != null && (!_skipButtonDismissed || _showControls)) AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, @@ -1916,6 +1993,15 @@ class _PlexVideoControlsState extends State with WindowListen }, onCancelAutoHide: () => _hideTimer?.cancel(), onStartAutoHide: _startHideTimer, + onSyncOffsetChanged: (propertyName, offset) { + setState(() { + if (propertyName == 'sub-delay') { + _subtitleSyncOffset = offset; + } else { + _audioSyncOffset = offset; + } + }); + }, serverId: widget.metadata.serverId ?? '', onBack: widget.onBack, canControl: widget.canControl, @@ -1924,7 +2010,7 @@ class _PlexVideoControlsState extends State with WindowListen onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, shaderService: widget.shaderService, onShaderChanged: widget.onShaderChanged, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, isLive: widget.isLive, liveChannelName: widget.liveChannelName, isAmbientLightingEnabled: widget.isAmbientLightingEnabled, diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index d85fedb4..be11cb26 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -4,7 +4,7 @@ import 'dart:io' show ProcessInfo; import 'package:flutter/scheduler.dart'; import '../../../../mpv/mpv.dart'; -import '../../../../mpv/player/player_android.dart'; +import '../../../../mpv/player/platform/player_android.dart'; import '../../../../utils/app_logger.dart'; import 'performance_stats.dart'; diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index f18ab5fa..7940d60b 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../../focus/dpad_navigator.dart'; +import '../../../focus/focusable_wrapper.dart'; import '../../../mpv/mpv.dart'; import '../../../i18n/strings.g.dart'; import '../../../theme/mono_tokens.dart'; @@ -17,6 +20,21 @@ class SyncOffsetControl extends StatefulWidget { final String labelText; // 'Audio' or 'Subtitles' final Future Function(int offset) onOffsetChanged; + /// When true, renders as a compact single-row layout for use in a top bar. + final bool compact; + + /// Focus node for the reset button (compact mode). When provided from the + /// parent, allows the close button's left-press to focus the reset button. + final FocusNode? resetFocusNode; + + /// Focus node for the close button (compact mode). When provided, pressing + /// select/enter on the slider moves focus here. + final FocusNode? closeFocusNode; + + /// Focus node for the slider (compact mode). When provided, allows the + /// parent to auto-focus the slider when the bar opens. + final FocusNode? sliderFocusNode; + const SyncOffsetControl({ super.key, required this.player, @@ -24,6 +42,10 @@ class SyncOffsetControl extends StatefulWidget { required this.initialOffset, required this.labelText, required this.onOffsetChanged, + this.compact = false, + this.resetFocusNode, + this.closeFocusNode, + this.sliderFocusNode, }); @override @@ -138,6 +160,8 @@ class _SyncOffsetControlState extends State { required IconData icon, required VoidCallback onTap, required VoidCallback onLongPressStart, + double size = 48, + double iconSize = 28, }) { return GestureDetector( onTap: onTap, @@ -145,16 +169,128 @@ class _SyncOffsetControlState extends State { onLongPressEnd: (_) => _stopLongPress(), onLongPressCancel: _stopLongPress, child: Container( - width: 48, - height: 48, + width: size, + height: size, decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8))), - child: Icon(icon, color: tokens(context).text, size: 28), + child: Icon(icon, color: tokens(context).text, size: iconSize), ), ); } @override Widget build(BuildContext context) { + return widget.compact ? _buildCompact(context) : _buildFull(context); + } + + Widget _buildCompactStepButton({ + required IconData icon, + required VoidCallback onTap, + required VoidCallback onLongPressStart, + }) { + return FocusableWrapper( + onSelect: onTap, + borderRadius: 18, + autoScroll: false, + useBackgroundFocus: true, + child: GestureDetector( + onTap: onTap, + onLongPressStart: (_) => onLongPressStart(), + onLongPressEnd: (_) => _stopLongPress(), + onLongPressCancel: _stopLongPress, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(8))), + child: Icon(icon, color: tokens(context).text, size: 22), + ), + ), + ); + } + + Widget _buildCompact(BuildContext context) { + final sliderValue = _currentOffset.clamp(_sliderMin, _sliderMax); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _buildCompactStepButton( + icon: Symbols.remove_rounded, + onTap: _decrementOffset, + onLongPressStart: _startLongPressDecrement, + ), + Expanded( + child: Focus( + onKeyEvent: (node, event) { + // Select/enter on the slider jumps focus to the close button + if (event.logicalKey.isSelectKey && event is KeyDownEvent) { + widget.closeFocusNode?.requestFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + canRequestFocus: false, + child: Slider( + focusNode: widget.sliderFocusNode, + value: sliderValue, + min: _sliderMin, + max: _sliderMax, + divisions: _sliderDivisions, + activeColor: Colors.blue, + inactiveColor: Theme.of(context).colorScheme.outlineVariant, + onChanged: (value) { + setState(() { + _currentOffset = value; + }); + }, + onChangeEnd: (value) { + _applyOffset(value); + }, + ), + ), + ), + _buildCompactStepButton( + icon: Symbols.add_rounded, + onTap: _incrementOffset, + onLongPressStart: _startLongPressIncrement, + ), + const SizedBox(width: 12), + SizedBox( + width: 80, + child: Text( + formatSyncOffset(_currentOffset), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 8), + FocusableWrapper( + focusNode: widget.resetFocusNode, + onSelect: _currentOffset != 0 ? _resetOffset : null, + borderRadius: 18, + autoScroll: false, + useBackgroundFocus: true, + child: GestureDetector( + onTap: _currentOffset != 0 ? _resetOffset : null, + child: Container( + width: 36, + height: 36, + alignment: Alignment.center, + child: AppIcon( + Symbols.restart_alt_rounded, + fill: 1, + color: _currentOffset != 0 ? tokens(context).text : tokens(context).textMuted, + size: 22, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildFull(BuildContext context) { // Clamp the slider value to its range, but display the actual offset final sliderValue = _currentOffset.clamp(_sliderMin, _sliderMax); diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index ec251858..ac3ebf05 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -1,6 +1,6 @@ import 'dart:async'; +import 'dart:typed_data'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../models/plex_media_info.dart'; import '../../../mpv/models.dart'; @@ -9,7 +9,6 @@ import '../../../focus/focusable_wrapper.dart'; import '../../../utils/formatters.dart'; import '../painters/buffer_range_painter.dart'; import '../painters/chapter_marker_painter.dart'; -import '../../plex_optimized_image.dart' show blurArtwork; /// Timeline slider with chapter markers for video playback /// @@ -36,8 +35,8 @@ class TimelineSlider extends StatefulWidget { /// Whether the slider is enabled for interaction. final bool enabled; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; const TimelineSlider({ super.key, @@ -52,7 +51,7 @@ class TimelineSlider extends StatefulWidget { this.onKeyEvent, this.onFocusChange, this.enabled = true, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, }); @override @@ -78,7 +77,7 @@ class _TimelineSliderState extends State { // Detect user-initiated seeks. A normal playback will advance the timeline // a very short amount. But a bigger jump indicates that the user changed position. // For now we will check half a second, but this can probably be made higher. - if (widget.thumbnailUrlBuilder != null && _dragValue == null) { + if (widget.thumbnailDataBuilder != null && _dragValue == null) { final delta = (widget.position.inMilliseconds - oldWidget.position.inMilliseconds).abs(); if (delta > 500) { _showKeySeekThumbnail = true; @@ -101,12 +100,8 @@ class _TimelineSliderState extends State { } Widget _buildTooltip(double sliderWidth, double pixelX, Duration time) { - // Snap to the nearest 5-second interval since Plex's thumbnails are generated every 5 seconds. - // Round here so the URL is consistent for widget-level cache hits rather than a new URL for each timestamp. - final roundedMs = (time.inMilliseconds / 5000).round() * 5000; - final roundedTime = Duration(milliseconds: roundedMs); - final thumbnailUrl = widget.thumbnailUrlBuilder?.call(roundedTime); - final hasThumbnail = thumbnailUrl != null; + final thumbnailData = widget.thumbnailDataBuilder?.call(time); + final hasThumbnail = thumbnailData != null; final tooltipWidth = hasThumbnail ? _thumbWidth : 64.0; final timestampOffset = 16.0; @@ -131,13 +126,12 @@ class _TimelineSliderState extends State { boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8, spreadRadius: 1)], ), clipBehavior: Clip.antiAlias, - child: blurArtwork(CachedNetworkImage( - imageUrl: thumbnailUrl, + child: Image.memory( + thumbnailData, fit: BoxFit.cover, - fadeInDuration: Duration.zero, - placeholder: (_, _) => const SizedBox.shrink(), // Show nothing for placeholder - errorWidget: (_, _, _) => const SizedBox.shrink(), // Show nothing for errors - )), + gaplessPlayback: true, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), ), if (hasThumbnail) const SizedBox(height: 4), Container( @@ -189,7 +183,7 @@ class _TimelineSliderState extends State { final fraction = ((_mousePosition! - _sliderPadding) / trackWidth).clamp(0.0, 1.0); final time = Duration(milliseconds: (fraction * durationMs).round()); tooltip = _buildTooltip(sliderWidth, _mousePosition!, time); - } else if (_showKeySeekThumbnail && widget.thumbnailUrlBuilder != null) { + } else if (_showKeySeekThumbnail && widget.thumbnailDataBuilder != null) { // Show tooltip at current playback position when user is actively seeking via d-pad/keyboard // Note that this has the lowest priority, so if the user hovers, that will show instead final fraction = (widget.position.inMilliseconds / durationMs).clamp(0.0, 1.0); @@ -248,25 +242,25 @@ class _TimelineSliderState extends State { overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), child: Semantics( - label: t.videoControls.timelineSlider, - slider: true, - child: Slider( - value: widget.duration.inMilliseconds > 0 ? widget.position.inMilliseconds.toDouble() : 0.0, - min: 0.0, - max: widget.duration.inMilliseconds.toDouble(), - onChanged: (value) { - setState(() => _dragValue = value); - widget.onSeek(Duration(milliseconds: value.toInt())); - }, - onChangeEnd: (value) { - setState(() => _dragValue = null); - widget.onSeekEnd(Duration(milliseconds: value.toInt())); - }, - activeColor: Colors.white, - inactiveColor: Colors.transparent, + label: t.videoControls.timelineSlider, + slider: true, + child: Slider( + value: widget.duration.inMilliseconds > 0 ? widget.position.inMilliseconds.toDouble() : 0.0, + min: 0.0, + max: widget.duration.inMilliseconds.toDouble(), + onChanged: (value) { + setState(() => _dragValue = value); + widget.onSeek(Duration(milliseconds: value.toInt())); + }, + onChangeEnd: (value) { + setState(() => _dragValue = null); + widget.onSeekEnd(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.transparent, + ), ), ), - ), ), // Chapter marker indicators if (widget.chaptersLoaded && widget.chapters.isNotEmpty && widget.duration.inMilliseconds > 0) diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index dfd00c65..9cab3bee 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -47,6 +47,7 @@ class TrackChapterControls extends StatelessWidget { final VoidCallback? onLoadSeekTimes; final VoidCallback? onCancelAutoHide; final VoidCallback? onStartAutoHide; + final void Function(String propertyName, int offset)? onSyncOffsetChanged; final String serverId; final ShaderService? shaderService; final VoidCallback? onShaderChanged; @@ -103,6 +104,7 @@ class TrackChapterControls extends StatelessWidget { this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, + this.onSyncOffsetChanged, this.focusNodes, this.onFocusChange, this.onNavigateLeft, @@ -221,6 +223,9 @@ class TrackChapterControls extends StatelessWidget { onShaderChanged: onShaderChanged, isAmbientLightingEnabled: isAmbientLightingEnabled, onToggleAmbientLighting: onToggleAmbientLighting, + onCancelAutoHide: onCancelAutoHide, + onStartAutoHide: onStartAutoHide, + onSyncOffsetChanged: onSyncOffsetChanged, ), ).whenComplete(() { onStartAutoHide?.call(); diff --git a/lib/widgets/video_controls/widgets/video_timeline_bar.dart b/lib/widgets/video_controls/widgets/video_timeline_bar.dart index 1447a189..64e17bd1 100644 --- a/lib/widgets/video_controls/widgets/video_timeline_bar.dart +++ b/lib/widgets/video_controls/widgets/video_timeline_bar.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; @@ -36,8 +38,8 @@ class VideoTimelineBar extends StatelessWidget { /// Whether to show the estimated finish time next to the remaining timestamp (mobile). final bool showFinishTime; - /// Optional callback that returns a thumbnail URL for a given timestamp. - final String Function(Duration time)? thumbnailUrlBuilder; + /// Optional callback that returns thumbnail image bytes for a given timestamp. + final Uint8List? Function(Duration time)? thumbnailDataBuilder; const VideoTimelineBar({ super.key, @@ -52,7 +54,7 @@ class VideoTimelineBar extends StatelessWidget { this.onFocusChange, this.enabled = true, this.showFinishTime = false, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, }); @override @@ -144,7 +146,7 @@ class VideoTimelineBar extends StatelessWidget { onKeyEvent: onKeyEvent, onFocusChange: onFocusChange, enabled: enabled, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, ); } } diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index cb152d01..c2028f6d 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -324,6 +324,11 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } + } else if (strcmp(method, "updateFrame") == 0) { + if (self->visible && self->texture) { + mpv_texture_mark_frame_available(self->texture); + } + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); } else if (strcmp(method, "isInitialized") == 0) { gboolean initialized = self->player && self->initialized; response = FL_METHOD_RESPONSE( diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index c02af024..daf8bf7e 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -831,7 +831,7 @@ repositoryURL = "https://github.com/edde746/MPVKit"; requirement = { kind = revision; - revision = e6afd7fa47b6a0f55c29028bbebb4a05b43e779f; + revision = 2e887368b44ce1dc9e1649e7757ec62c2564e792; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 93c870f8..7ecb3324 100644 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "e6afd7fa47b6a0f55c29028bbebb4a05b43e779f" + "revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792" } } ], diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 93c870f8..7ecb3324 100644 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/MPVKit", "state" : { - "revision" : "e6afd7fa47b6a0f55c29028bbebb4a05b43e779f" + "revision" : "2e887368b44ce1dc9e1649e7757ec62c2564e792" } } ], diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 78c36cf4..4fc43ef1 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/macos/Runner/MpvPlayer/MpvPlayerCore.swift b/macos/Runner/MpvPlayer/MpvPlayerCore.swift index 8b1bac76..762d1512 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerCore.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerCore.swift @@ -167,6 +167,7 @@ class MpvPlayerCore: NSObject { checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "ao", "avfoundation,coreaudio")) checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes")) checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "mailbox")) diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 08ba3a3f..04315f36 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-write + diff --git a/pubspec.lock b/pubspec.lock index b1ce06ad..bd16bc25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - archive: - dependency: transitive - description: - name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" - url: "https://pub.dev" - source: hosted - version: "4.0.7" args: dependency: transitive description: @@ -215,10 +207,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -481,21 +473,13 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" url: "https://pub.dev" source: hosted version: "3.4.1" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" flutter_lints: dependency: "direct dev" description: @@ -568,7 +552,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -591,14 +575,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" - url: "https://pub.dev" - source: hosted - version: "4.5.4" in_app_review: dependency: "direct main" description: @@ -699,18 +675,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -896,14 +872,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" - posix: - dependency: transitive - description: - name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" - url: "https://pub.dev" - source: hosted - version: "6.0.3" process: dependency: transitive description: @@ -1281,10 +1249,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ac91bb85..a511a848 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: plezy description: "A beautiful Plex client for Flutter" publish_to: "none" -version: 1.21.1+46 +version: 1.21.3+48 environment: sdk: ^3.8.1 @@ -14,6 +14,8 @@ dependencies: json_annotation: ^4.9.0 shared_preferences: ^2.2.2 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 + http: ^1.2.0 url_launcher: ^6.3.0 uuid: ^4.4.0 window_manager: ^0.5.1 @@ -65,7 +67,6 @@ dev_dependencies: flutter_lints: ^6.0.0 build_runner: ^2.4.7 json_serializable: ^6.7.1 - flutter_launcher_icons: ^0.14.4 slang_build_runner: ^4.12.0 dart_code_linter: ^3.2.1 drift_dev: ^2.14.0 @@ -81,22 +82,9 @@ flutter: assets: - lib/data/iso_639_codes.json - assets/plezy.png + - assets/plezy_adaptive_foreground.svg - assets/go-noto-current-regular.ttf - assets/shaders/nvscaler/ - assets/shaders/anime4k/ - assets/player_icons/ - assets/rating_icons/ - -flutter_launcher_icons: - android: true - ios: false - image_path: "assets/plezy.png" - adaptive_icon_background: "#ffffff" - adaptive_icon_foreground: "assets/plezy_android_foreground.png" - adaptive_icon_monochrome: "assets/plezy_monochrome.png" - macos: - generate: false - windows: - generate: true - linux: - generate: true diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..fb6c4863 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,4 @@ +sonar.projectKey=edde746_plezy +sonar.organization=edde746 +sonar.sources=lib +sonar.exclusions=**/*.g.dart