From 45e792e3ab80c2eea02f5e3e25b5f8cb5367b9f4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:17:24 +0100 Subject: [PATCH 01/64] fix: ESC exits fullscreen without closing video player close #533 --- lib/screens/video_player_screen.dart | 12 +++++- .../video_controls/video_controls.dart | 42 +++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 146e1f51..5fe69e32 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -171,6 +171,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin // key events never escape the video player route. late final FocusNode _screenFocusNode; + // Cached setting: when false on Windows/Linux, ESC should not exit the player + bool _videoPlayerNavigationEnabled = false; + // App lifecycle state tracking bool _wasPlayingBeforeInactive = false; @@ -393,6 +396,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(); @@ -2327,7 +2331,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 diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d9017c55..0864f22c 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -14,6 +14,7 @@ import 'package:flutter/services.dart' PhysicalKeyboardKey, KeyEvent, KeyDownEvent, + KeyUpEvent, HardwareKeyboard; import '../../services/fullscreen_state_manager.dart'; import '../../services/macos_window_service.dart'; @@ -1288,6 +1289,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 +1379,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 +1417,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, @@ -1521,12 +1545,16 @@ 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; From cb39945a9de9ba1c5ff925b5e47465db2702c9e3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:43:44 +0100 Subject: [PATCH 02/64] fix: watch together clock drift close #534 --- .../services/watch_together_sync_manager.dart | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) 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; From b193a2352494acf4d4d49495adbf648ef3cb52d1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:16:38 +0100 Subject: [PATCH 03/64] fix: video stuck on still frame after long pause close #536 --- lib/mpv/player/player_native.dart | 3 +-- lib/screens/video_player_screen.dart | 4 ++++ lib/services/playback_progress_tracker.dart | 16 ++++++++++++++++ linux/runner/mpv/mpv_plugin.cc | 5 +++++ 4 files changed, 26 insertions(+), 2 deletions(-) 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/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5fe69e32..efe4b534 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1698,6 +1698,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(); } 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/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( From cb6da03c107c11f39d3f9a4d63ea27354d1d74e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 08:24:36 +0000 Subject: [PATCH 04/64] chore: bump version to 1.21.2 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ac91bb85..d10866fc 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.2+47 environment: sdk: ^3.8.1 From 4f4192b26179544fdab586d95e0a01cb66004106 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 09:03:19 +0000 Subject: [PATCH 05/64] chore: update cask to 1.21.2 --- Casks/plezy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Casks/plezy.rb b/Casks/plezy.rb index 11738247..fd153ea3 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.2" + sha256 "65dd027f58b92e9c41601b11b9603a4ba76b15947913164398577fb476021b8b" url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg" name "Plezy" From aa6a5ec234880c37adb4a725a31ccf9490019e0c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:18:39 +0100 Subject: [PATCH 06/64] docs: update HDR platform support info --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 93f693c5c48f2eab76a4627460cf83817816f062 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:59:06 +0100 Subject: [PATCH 07/64] fix: macos folder picker entitlement --- macos/Runner/DebugProfile.entitlements | 2 ++ macos/Runner/Release.entitlements | 2 ++ 2 files changed, 4 insertions(+) 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/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 + From fc5fcbb0db267c07f292a6b7fa628dbc65a9eb97 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:13:53 +0100 Subject: [PATCH 08/64] fix: google tv virtual remote dpad --- .../kotlin/com/edde746/plezy/MainActivity.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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..c23b5ff7 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 @@ -25,6 +27,7 @@ class MainActivity : FlutterActivity() { private val PIP_CHANNEL = "app.plezy/pip" private val EXTERNAL_PLAYER_CHANNEL = "app.plezy/external_player" private var watchNextPlugin: WatchNextPlugin? = null + private var cachedFlutterView: android.view.View? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,6 +42,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 From 88d7e2d3190e6081fcd4249d9a4a283e3eefb6ed Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:38:58 +0100 Subject: [PATCH 09/64] feat: add audio passthrough toggle for desktop --- lib/i18n/de.i18n.json | 3 +- lib/i18n/en.i18n.json | 3 +- lib/i18n/es.i18n.json | 3 +- lib/i18n/fr.i18n.json | 3 +- lib/i18n/it.i18n.json | 3 +- lib/i18n/ko.i18n.json | 3 +- lib/i18n/nl.i18n.json | 3 +- lib/i18n/strings_de.g.dart | 2 ++ lib/i18n/strings_en.g.dart | 4 +++ lib/i18n/strings_es.g.dart | 2 ++ lib/i18n/strings_fr.g.dart | 2 ++ lib/i18n/strings_it.g.dart | 2 ++ lib/i18n/strings_ko.g.dart | 2 ++ lib/i18n/strings_nl.g.dart | 2 ++ lib/i18n/strings_sv.g.dart | 2 ++ lib/i18n/strings_zh.g.dart | 2 ++ lib/i18n/sv.i18n.json | 3 +- lib/i18n/zh.i18n.json | 3 +- lib/screens/video_player_screen.dart | 7 +++++ lib/services/settings_service.dart | 11 +++++++ .../sheets/video_settings_sheet.dart | 30 +++++++++++++++++++ 21 files changed, 86 insertions(+), 9 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 254c0e44..91564fe0 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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..0546a4ad 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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..0eb849a1 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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..2c778ec7 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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..6afb3ef4 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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..568f8682 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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..4c8a834c 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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..aa3b2248 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1018,6 +1018,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 @@ -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..b65113db 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2249,6 +2249,9 @@ class TranslationsVideoSettingsEn { /// en: 'Performance Overlay' String get performanceOverlay => 'Performance Overlay'; + + /// en: 'Audio Passthrough' + String get audioPassthrough => 'Audio Passthrough'; } // Path: externalPlayer @@ -3620,6 +3623,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..e11c72a1 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1018,6 +1018,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 @@ -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..77e93c6e 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1018,6 +1018,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 @@ -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..4b9b882c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1018,6 +1018,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 @@ -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..c6b8f173 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1018,6 +1018,7 @@ class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn { @override String get hdr => 'HDR'; @override String get audioOutput => '오디오 출력'; @override String get performanceOverlay => '성능 오버레이'; + @override String get audioPassthrough => '오디오 패스스루'; } // Path: externalPlayer @@ -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..5709fcb8 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1018,6 +1018,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 @@ -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..c4f1e0ce 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1018,6 +1018,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 @@ -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..4cfb9323 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1018,6 +1018,7 @@ class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn { @override String get hdr => 'HDR'; @override String get audioOutput => '音频输出'; @override String get performanceOverlay => '性能监控'; + @override String get audioPassthrough => '音频直通'; } // Path: externalPlayer @@ -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..b9370a02 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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..867c769b 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -806,7 +806,8 @@ "subtitleSync": "字幕同步", "hdr": "HDR", "audioOutput": "音频输出", - "performanceOverlay": "性能监控" + "performanceOverlay": "性能监控", + "audioPassthrough": "音频直通" }, "externalPlayer": { "title": "外部播放器", diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index efe4b534..7c4899f4 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -433,6 +433,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(); diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index cb68e9e5..518a9c67 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -77,6 +77,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._(); @@ -1129,6 +1130,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([ @@ -1184,6 +1194,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyCustomExternalPlayers), prefs.remove(_keyConfirmExitOnBack), prefs.remove(_keyAmbientLighting), + prefs.remove(_keyAudioPassthrough), prefs.remove(_keyBufferSizeMigratedToAuto), ]); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 9b854ece..2c94a438 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -115,6 +115,7 @@ class _VideoSettingsSheetState extends State { bool _enableHDR = true; bool _showPerformanceOverlay = false; bool _autoPlayNextEpisode = true; + bool _audioPassthrough = false; @override void initState() { @@ -131,6 +132,7 @@ class _VideoSettingsSheetState extends State { _enableHDR = settings.getEnableHDR(); _showPerformanceOverlay = settings.getShowPerformanceOverlay(); _autoPlayNextEpisode = settings.getAutoPlayNextEpisode(); + _audioPassthrough = settings.getAudioPassthrough(); }); } @@ -166,6 +168,17 @@ 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) { setState(() { _currentView = view; @@ -331,6 +344,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( From 6188d21c3d23f64d9d5a13ae70ed403da01ccb15 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:01:43 +0100 Subject: [PATCH 10/64] perf: limit concurrent image transcode requests --- lib/main.dart | 3 ++- lib/services/image_cache_service.dart | 29 +++++++++++++++++++++++++++ lib/widgets/plex_optimized_image.dart | 2 ++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 lib/services/image_cache_service.dart diff --git a/lib/main.dart b/lib/main.dart index d2def9fc..08acff74 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,7 +79,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 = >[]; 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/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, ), From 728c4854a5be40ab274155809e638421197bfccd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:05:36 +0100 Subject: [PATCH 11/64] feat: sparse-map pagination for library browse --- lib/screens/libraries/libraries_screen.dart | 4 +- .../libraries/tabs/base_library_tab.dart | 9 +- .../libraries/tabs/library_browse_tab.dart | 404 +++++++++++++----- lib/services/plex_client.dart | 15 +- pubspec.lock | 16 +- 5 files changed, 317 insertions(+), 131 deletions(-) diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 5c7cb83e..1f0c7784 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -524,7 +524,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 +533,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(); 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..b8321fcd 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -18,6 +18,7 @@ import '../../../widgets/alpha_jump_bar.dart'; import '../../../widgets/alpha_jump_helper.dart'; import '../../../widgets/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(); @@ -710,11 +820,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? _totalSize - 1 : 0; + final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex); if (lastInRow != _currentFirstVisibleIndex) { setState(() => _currentFirstVisibleIndex = lastInRow); } @@ -754,7 +871,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 +884,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 +924,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 +981,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 +1006,48 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _filters.isNotEmpty && _selectedGrouping != 'folders'; @@ -976,11 +1120,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 +1137,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 +1249,34 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 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) { @@ -372,7 +379,7 @@ class PlexClient { } /// Get library content by section ID - Future> getLibraryContent( + Future getLibraryContent( String sectionId, { int? start, int? size, @@ -394,7 +401,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 diff --git a/pubspec.lock b/pubspec.lock index b1ce06ad..99582c4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -215,10 +215,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: @@ -699,18 +699,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: @@ -1281,10 +1281,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: From c480e44e0ec0a3fbfd6b73de43e41c007b291376 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:53:58 +0100 Subject: [PATCH 12/64] fix: skeleton card visibility and layout --- .../libraries/tabs/library_browse_tab.dart | 48 +++++++++++-------- lib/widgets/media_card.dart | 2 +- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b8321fcd..1dc6b04a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1257,26 +1257,36 @@ class _SkeletonCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Poster area - const Expanded(child: SkeletonLoader()), - const SizedBox(height: 4), - // Title bar - const SkeletonLoader( - child: SizedBox(height: 10, width: double.infinity), - ), - const SizedBox(height: 3), - // Subtitle bar - const FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: 0.6, - child: SkeletonLoader( - child: SizedBox(height: 8, width: double.infinity), + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster area — matches the Expanded poster in _buildGridCard + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: const SkeletonLoader(child: SizedBox.expand()), + ), ), - ), - ], + const SizedBox(height: 4), + // Title bar + SkeletonLoader( + borderRadius: BorderRadius.circular(4), + child: const SizedBox(height: 13, width: double.infinity), + ), + const SizedBox(height: 3), + // Subtitle bar + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: 0.6, + child: SkeletonLoader( + borderRadius: BorderRadius.circular(4), + child: const SizedBox(height: 11), + ), + ), + ], + ), ); } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index d8a7df17..2b9eb2fb 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -828,7 +828,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, From bf3f5282f2ebfd3d90fe2faffa7995cea6ba9a62 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:02:59 +0100 Subject: [PATCH 13/64] fix: skip intro button staying on screen close #550 --- lib/models/plex_media_info.dart | 2 +- lib/widgets/video_controls/video_controls.dart | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 5b130173..5b32221c 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -173,7 +173,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/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 0864f22c..a46016a5 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -406,6 +406,9 @@ 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); } From 3460fa8b325c7c8821c0b805b4d0970fa9265aad Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:14:46 +0100 Subject: [PATCH 14/64] fix: download retry failing with metadata cache miss close #555 --- lib/services/download_manager_service.dart | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index ab37a94a..d4779c15 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -451,9 +451,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,8 +507,20 @@ 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'); From 0f4920cc186bfb03c2cedffe7ecea40615f67406 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:42:15 +0100 Subject: [PATCH 15/64] fix: reduce ExoPlayer buffer limits close #546 --- .../com/edde746/plezy/exoplayer/ExoPlayerCore.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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..d053f24a 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 @@ -356,16 +356,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") From 383351d7696ca99430d2fdaa81a1ba0d6a89d46b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:58:44 +0100 Subject: [PATCH 16/64] fix: detect invalid tokens and prevent startup hangs close #553 --- lib/main.dart | 20 ++++++++++++++---- lib/providers/offline_mode_provider.dart | 4 +++- lib/services/multi_server_manager.dart | 15 +++++++------- lib/services/plex_client.dart | 13 +++++++++++- lib/services/server_registry.dart | 26 ++++++++++++++++++------ 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 08acff74..84820f9a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -378,13 +378,25 @@ class _SetupScreenState extends State { 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(); + // 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, MaterialPageRoute(builder: (context) => const AuthScreen())); + } + return; + } } // Load all configured servers 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/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 8e6b99d6..9afd4282 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -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'); } }); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index c1cc4bd6..86eb09c6 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -233,7 +233,7 @@ class PlexClient { final response = await dio.get('/', options: Options(headers: {'X-Plex-Token': token})); stopwatch.stop(); - final success = response.statusCode == 200 || response.statusCode == 401; + final success = response.statusCode == 200; return ConnectionTestResult( success: success, @@ -371,6 +371,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 { 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; } } } From 3279bc7578c110e1240cb45663241d0937f3e8db Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:01:55 +0100 Subject: [PATCH 17/64] fix: sanitize dots in download filenames close #556 --- lib/services/download_storage_service.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 0c6d84c1..4ca77b81 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -222,7 +222,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 From acf91107adc6d87f2250f60ae286a2f18baeab94 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:29:18 +0100 Subject: [PATCH 18/64] fix: split combined recently added hubs by library closes #552 --- lib/models/plex_metadata.dart | 4 + lib/models/plex_metadata.g.dart | 2 + lib/services/data_aggregation_service.dart | 109 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index a6801b6c..25e45c20 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; @@ -174,6 +175,7 @@ class PlexMetadata with MultiServerFields { this.playlistItemID, this.playQueueItemID, this.librarySectionID, + this.librarySectionTitle, this.ratingImage, this.audienceRatingImage, this.tagline, @@ -229,6 +231,7 @@ class PlexMetadata with MultiServerFields { int? playlistItemID, int? playQueueItemID, int? librarySectionID, + String? librarySectionTitle, String? ratingImage, String? audienceRatingImage, String? tagline, @@ -282,6 +285,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, diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index cd57d744..eb2a598e 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?, @@ -100,6 +101,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 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. From 659b59d4507221b7db89cd60562bd608f010d339 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 01:08:25 +0100 Subject: [PATCH 19/64] fix: remove companion remote connection history --- lib/i18n/de.i18n.json | 10 +- lib/i18n/en.i18n.json | 10 +- lib/i18n/es.i18n.json | 10 +- lib/i18n/fr.i18n.json | 10 +- lib/i18n/it.i18n.json | 10 +- lib/i18n/ko.i18n.json | 10 +- lib/i18n/nl.i18n.json | 10 +- lib/i18n/strings_de.g.dart | 16 -- lib/i18n/strings_en.g.dart | 31 --- lib/i18n/strings_es.g.dart | 16 -- lib/i18n/strings_fr.g.dart | 16 -- lib/i18n/strings_it.g.dart | 16 -- lib/i18n/strings_ko.g.dart | 16 -- lib/i18n/strings_nl.g.dart | 16 -- lib/i18n/strings_sv.g.dart | 16 -- lib/i18n/strings_zh.g.dart | 16 -- lib/i18n/sv.i18n.json | 10 +- lib/i18n/zh.i18n.json | 10 +- .../recent_remote_session.dart | 55 ---- .../recent_remote_session.g.dart | 25 -- lib/providers/companion_remote_provider.dart | 92 +------ .../companion_remote/pairing_screen.dart | 239 ++---------------- .../companion_remote_discovery_service.dart | 95 ------- 23 files changed, 30 insertions(+), 725 deletions(-) delete mode 100644 lib/models/companion_remote/recent_remote_session.dart delete mode 100644 lib/models/companion_remote/recent_remote_session.g.dart delete mode 100644 lib/services/companion_remote/companion_remote_discovery_service.dart diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 91564fe0..b8a04f1f 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0546a4ad..646a4b08 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 0eb849a1..80173972 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 2c778ec7..93e508af 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -732,11 +732,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 +747,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 +756,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 ?", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 6afb3ef4..971139c9 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 568f8682..989506d2 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -732,11 +732,8 @@ "minimize": "최소화" }, "pairing": { - "recent": "최근", "scan": "스캔", "manual": "수동", - "recentConnections": "최근 연결", - "quickReconnect": "이전에 페어링한 기기에 빠르게 재연결", "pairWithDesktop": "데스크톱과 페어링", "enterSessionDetails": "데스크톱 기기에 표시된 세션 정보를 입력하세요", "hostAddressHint": "192.168.1.100:48632", @@ -750,11 +747,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 +756,7 @@ "validationPinLength": "PIN은 6자리여야 합니다", "connectionTimedOut": "연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.", "sessionNotFound": "세션을 찾을 수 없습니다. 자격 증명을 확인하세요.", - "failedToConnect": "연결 실패: ${error}", - "failedToLoadRecent": "최근 세션 로드 실패: ${error}" + "failedToConnect": "연결 실패: ${error}" }, "remote": { "disconnectConfirm": "원격 세션 연결을 해제하시겠습니까?", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 4c8a834c..4024209a 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index aa3b2248..0c096bfd 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b65113db..061f0d0d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -2694,21 +2694,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'; @@ -2748,21 +2739,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'; @@ -2790,8 +2769,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 @@ -3553,11 +3530,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', @@ -3571,11 +3545,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', @@ -3585,7 +3555,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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index e11c72a1..824569b0 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 77e93c6e..c9416902 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 4b9b882c..ee2377a2 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index c6b8f173..bbf1c283 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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를 입력하세요'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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를 입력하세요', @@ -1982,7 +1967,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 시도 중', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 5709fcb8..281473fe 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index c4f1e0ce..8a7b1380 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 4cfb9323..a79bc4ce 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1214,11 +1214,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'; @@ -1232,11 +1229,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'; @@ -1246,7 +1239,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 @@ -1950,11 +1942,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', @@ -1968,11 +1957,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', @@ -1982,7 +1967,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 次', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index b9370a02..02deee9d 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -732,11 +732,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 +747,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 +756,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?", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 867c769b..b8243920 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -732,11 +732,8 @@ "minimize": "最小化" }, "pairing": { - "recent": "最近", "scan": "扫描", "manual": "手动", - "recentConnections": "最近连接", - "quickReconnect": "快速重新连接之前配对的设备", "pairWithDesktop": "与桌面配对", "enterSessionDetails": "输入桌面设备上显示的会话信息", "hostAddressHint": "192.168.1.100:48632", @@ -750,11 +747,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 +756,7 @@ "validationPinLength": "PIN 必须为6位数字", "connectionTimedOut": "连接超时。请检查会话 ID 和 PIN。", "sessionNotFound": "找不到会话。请检查您的凭据。", - "failedToConnect": "连接失败:${error}", - "failedToLoadRecent": "加载最近会话失败:${error}" + "failedToConnect": "连接失败:${error}" }, "remote": { "disconnectConfirm": "是否要断开远程会话的连接?", 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/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 7bb4f6cd..3e9f5107 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -10,8 +10,6 @@ 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'; @@ -21,11 +19,9 @@ 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'; @@ -46,7 +42,6 @@ class CompanionRemoteProvider with ChangeNotifier { StreamSubscription? _deviceDisconnectedSubscription; StreamSubscription? _errorSubscription; StreamSubscription? _statusSubscription; - StreamSubscription>? _recentSessionsSubscription; CommandReceivedCallback? onCommandReceived; DeviceApprovalCallback? onDeviceApprovalRequired; @@ -61,7 +56,6 @@ class CompanionRemoteProvider with ChangeNotifier { 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() { @@ -165,7 +159,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 +172,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(); } } @@ -470,91 +461,10 @@ class CompanionRemoteProvider with ChangeNotifier { 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/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/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(); - } -} From 3418241c0463aecf9e16ee6bbe88630976470deb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:14:22 +0100 Subject: [PATCH 20/64] fix: disable tunneling for FFmpeg-only audio codecs --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) 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 d053f24a..4dd252cf 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 @@ -90,6 +90,7 @@ 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 var delegate: ExoPlayerDelegate? = null var isInitialized: Boolean = false private set @@ -532,6 +533,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { override fun onTracksChanged(tracks: Tracks) { Log.d(TAG, "onTracksChanged") + evaluateAudioCodecForTunneling() emitTrackList() } @@ -716,6 +718,62 @@ 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 + Log.d(TAG, "updateTunnelingState: speed=$currentSpeed, codecDisabled=$tunnelingDisabledForCodec, tunneling=$shouldTunnel") + selector.setParameters( + selector.buildUponParameters() + .setTunnelingEnabled(shouldTunnel) + ) + } + + 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) { @@ -724,6 +782,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { currentMediaUri = uri currentHeaders = headers externalSubtitles.clear() + tunnelingDisabledForCodec = false if (isLive) { // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells @@ -801,14 +860,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()) } @@ -825,9 +877,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) @@ -1310,6 +1376,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { memoryCallback?.let { activity.unregisterComponentCallbacks(it) } memoryCallback = null + tunnelingDisabledForCodec = false exoPlayer?.clearVideoSurface() exoPlayer?.removeListener(this) exoPlayer?.release() From 98776b40f5ba18b369ff989b373b347de491cc88 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:40:52 +0100 Subject: [PATCH 21/64] fix: show sync controls as compact top bar close #548 --- lib/widgets/overlay_sheet.dart | 50 +++-- .../desktop_video_controls.dart | 3 + .../sheets/video_settings_sheet.dart | 184 ++++++++++++++---- .../video_controls/video_controls.dart | 38 +++- .../widgets/sync_offset_control.dart | 142 +++++++++++++- .../widgets/track_chapter_controls.dart | 5 + 6 files changed, 365 insertions(+), 57 deletions(-) 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/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 9f306562..74880b43 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; @@ -138,6 +139,7 @@ class DesktopVideoControls extends StatefulWidget { this.onLoadSeekTimes, this.onCancelAutoHide, this.onStartAutoHide, + this.onSyncOffsetChanged, this.serverId = '', this.onBack, this.canControl = true, @@ -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/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 2c94a438..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 @@ -180,12 +193,65 @@ class _VideoSettingsSheetState extends State { } 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; @@ -453,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) { @@ -668,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: @@ -680,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 a46016a5..d4a0623f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -657,16 +657,20 @@ class _PlexVideoControlsState extends State with WindowListen 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() { @@ -922,6 +926,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, @@ -1947,6 +1960,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, 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/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(); From 5740d93a35f73461b2874b42a3f9816db86475eb Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:41:27 +0100 Subject: [PATCH 22/64] fix: download from homepage failing --- lib/providers/download_provider.dart | 21 ++++++++++----------- lib/services/download_manager_service.dart | 12 ++++++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) 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/services/download_manager_service.dart b/lib/services/download_manager_service.dart index d4779c15..e0119170 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -522,8 +522,16 @@ class DownloadManagerService { } } - 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'; From e25fb4c10facc97ef3a2c0ea3316d727d27a08af Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:19:17 +0100 Subject: [PATCH 23/64] fix: ExoPlayer resume position lost on tunneling change --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 4dd252cf..b9747590 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 @@ -91,6 +91,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { 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 @@ -520,6 +521,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() @@ -750,11 +761,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val player = exoPlayer ?: return val currentSpeed = player.playbackParameters.speed val shouldTunnel = (currentSpeed == 1f) && !tunnelingDisabledForCodec - Log.d(TAG, "updateTunnelingState: speed=$currentSpeed, codecDisabled=$tunnelingDisabledForCodec, tunneling=$shouldTunnel") + 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() { @@ -783,6 +801,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { currentHeaders = headers externalSubtitles.clear() tunnelingDisabledForCodec = false + pendingStartPositionMs = startPositionMs if (isLive) { // Live MKV streams lack Cues (seek index). FLAG_DISABLE_SEEK_FOR_CUES tells @@ -1377,6 +1396,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { memoryCallback = null tunnelingDisabledForCodec = false + pendingStartPositionMs = 0L exoPlayer?.clearVideoSurface() exoPlayer?.removeListener(this) exoPlayer?.release() From a32422bb5ee06ba0c4136efc2eafe3d302682792 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 04:30:48 +0100 Subject: [PATCH 24/64] chore: remove unused formatRelativeTime --- lib/utils/formatters.dart | 37 ------------------------------------- 1 file changed, 37 deletions(-) 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(' · '); From 8f24bd478a363c3a28236995bfe0fdd8935caeba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 03:32:43 +0000 Subject: [PATCH 25/64] chore: bump version to 1.21.3 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index d10866fc..0acf7863 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.2+47 +version: 1.21.3+48 environment: sdk: ^3.8.1 From cbf9913e8aaefe3c36af2f65da14b2ab35615f64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 26 Feb 2026 04:10:17 +0000 Subject: [PATCH 26/64] chore: update cask to 1.21.3 --- Casks/plezy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Casks/plezy.rb b/Casks/plezy.rb index fd153ea3..3d01fd88 100644 --- a/Casks/plezy.rb +++ b/Casks/plezy.rb @@ -1,6 +1,6 @@ cask "plezy" do - version "1.21.2" - sha256 "65dd027f58b92e9c41601b11b9603a4ba76b15947913164398577fb476021b8b" + version "1.21.3" + sha256 "81c643d9d67ed71ffa90d5ac36da6ef23fd0f71d3ae69f690cbe7710d511d74e" url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg" name "Plezy" From 414e5804eb2d1dbca9351ae7ddf1feacbbb14807 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:48:32 +0100 Subject: [PATCH 27/64] feat: add hide spoilers setting for unwatched episodes --- lib/i18n/de.i18n.json | 2 ++ lib/i18n/en.i18n.json | 2 ++ lib/i18n/es.i18n.json | 2 ++ lib/i18n/fr.i18n.json | 2 ++ lib/i18n/it.i18n.json | 2 ++ lib/i18n/ko.i18n.json | 2 ++ lib/i18n/nl.i18n.json | 2 ++ lib/i18n/strings_de.g.dart | 4 +++ lib/i18n/strings_en.g.dart | 8 +++++ lib/i18n/strings_es.g.dart | 4 +++ lib/i18n/strings_fr.g.dart | 4 +++ lib/i18n/strings_it.g.dart | 4 +++ lib/i18n/strings_ko.g.dart | 4 +++ lib/i18n/strings_nl.g.dart | 4 +++ lib/i18n/strings_sv.g.dart | 4 +++ lib/i18n/strings_zh.g.dart | 4 +++ lib/i18n/sv.i18n.json | 2 ++ lib/i18n/zh.i18n.json | 2 ++ lib/providers/settings_provider.dart | 10 ++++++ lib/screens/season_detail_screen.dart | 22 +++++++++++-- lib/screens/settings/settings_screen.dart | 15 +++++++++ lib/services/settings_service.dart | 11 +++++++ lib/utils/content_utils.dart | 9 +++++ lib/widgets/media_card.dart | 40 ++++++++++++++++------- 24 files changed, 150 insertions(+), 15 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index b8a04f1f..f4fb1154 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 646a4b08..dd069605 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 80173972..3440384f 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 93e508af..585b23e0 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 971139c9..9c787968 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 989506d2..fa95896b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -117,6 +117,8 @@ "alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다", "showUnwatchedCount": "미시청 수 표시", "showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시", + "hideSpoilers": "미시청 에피소드 스포일러 숨기기", + "hideSpoilersDescription": "아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다", "playerBackend": "플레이어 백엔드", "exoPlayer": "ExoPlayer (권장)", "exoPlayerDescription": "더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 4024209a..6269ccd2 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 0c096bfd..8a362f19 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 061f0d0d..2da53df6 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -454,6 +454,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'; @@ -2982,6 +2988,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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 824569b0..6d5f756c 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index c9416902..53dab1ac 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index ee2377a2..20ea62c6 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index bbf1c283..c34fb3f1 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -236,6 +236,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 네이티브 플레이어'; @@ -1394,6 +1396,8 @@ extension on TranslationsKo { 'settings.alwaysKeepSidebarOpenDescription' => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다', 'settings.showUnwatchedCount' => '미시청 수 표시', 'settings.showUnwatchedCountDescription' => '시리즈 및 시즌에 미시청 에피소드 수 표시', + 'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기', + 'settings.hideSpoilersDescription' => '아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다', 'settings.playerBackend' => '플레이어 백엔드', 'settings.exoPlayer' => 'ExoPlayer (권장)', 'settings.exoPlayerDescription' => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 281473fe..da12c779 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 8a7b1380..b7d2868b 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -236,6 +236,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'; @@ -1394,6 +1396,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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index a79bc4ce..37a6fff0 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -236,6 +236,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 原生播放器,硬件支持更好'; @@ -1394,6 +1396,8 @@ extension on TranslationsZh { 'settings.alwaysKeepSidebarOpenDescription' => '侧边栏保持展开状态,内容区域自动调整', 'settings.showUnwatchedCount' => '显示未观看数量', 'settings.showUnwatchedCountDescription' => '在剧集和季上显示未观看的集数', + 'settings.hideSpoilers' => '隐藏未看剧集的剧透内容', + 'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图并隐藏其描述', 'settings.playerBackend' => '播放器引擎', 'settings.exoPlayer' => 'ExoPlayer(推荐)', 'settings.exoPlayerDescription' => 'Android 原生播放器,硬件支持更好', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 02deee9d..2f52a17e 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -117,6 +117,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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index b8243920..3453e72a 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -117,6 +117,8 @@ "alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整", "showUnwatchedCount": "显示未观看数量", "showUnwatchedCountDescription": "在剧集和季上显示未观看的集数", + "hideSpoilers": "隐藏未看剧集的剧透内容", + "hideSpoilersDescription": "模糊未观看剧集的缩略图并隐藏其描述", "playerBackend": "播放器引擎", "exoPlayer": "ExoPlayer(推荐)", "exoPlayerDescription": "Android 原生播放器,硬件支持更好", 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/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 9336b884..13ad8091 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/plex_optimized_image.dart'; import '../models/plex_metadata.dart'; @@ -383,6 +386,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 && @@ -430,7 +436,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 @@ -634,8 +650,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), Text( widget.episode.summary!, diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index a7dc66a0..b2629ff8 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.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/services/settings_service.dart b/lib/services/settings_service.dart index 518a9c67..4b830db9 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'; @@ -1037,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); @@ -1187,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), 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/widgets/media_card.dart b/lib/widgets/media_card.dart index 2b9eb2fb..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( From a38aac78291505ea24f58e108eaf53a5a5261d3e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:52:58 +0100 Subject: [PATCH 28/64] fix: artwork changing not working - Use PUT instead of POST and singular element path (art/poster) to match Plex Web API behavior - Set content type to application/octet-stream for binary uploads - Add GestureDetector for mouse/touch tap support in artwork picker - Always refresh detail screen metadata after returning from edit --- lib/screens/media_detail_screen.dart | 4 +- lib/services/plex_client.dart | 13 ++++-- lib/widgets/artwork_picker_dialog.dart | 57 ++++++++++++++------------ 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9fd2313c..0f357a37 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -713,11 +713,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(); } }, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 86eb09c6..7aac83c8 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1577,19 +1577,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/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), + ), + ), + ], + ), ), ); }, From a5fa8a4c99f801a31746dab29f55c87f53da6416 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:21:13 +0100 Subject: [PATCH 29/64] fix: don't clear buffer state on playback restart --- lib/mpv/player/player_base.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index c018ce4a..82a06537 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -331,10 +331,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; From 79350b8d71e069dccccdaa0a02a825732f8ece1a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:22:52 +0100 Subject: [PATCH 30/64] feat: set avfoundation as preferred audio output on macOS --- macos/Runner/MpvPlayer/MpvPlayerCore.swift | 1 + 1 file changed, 1 insertion(+) 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")) From 4b6c82b0719ec861b0472610f6fcd185b6fce5e5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:52:35 +0100 Subject: [PATCH 31/64] fix: hide alpha jump bar for small libraries --- lib/screens/libraries/tabs/library_browse_tab.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1dc6b04a..276975e9 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -786,6 +786,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState Date: Thu, 26 Feb 2026 11:47:55 +0100 Subject: [PATCH 32/64] feat: themed splash screen with status messages --- .../kotlin/com/edde746/plezy/MainActivity.kt | 59 ++++++++++++++++ .../drawable-hdpi/ic_launcher_foreground.png | Bin 5248 -> 0 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 3220 -> 0 bytes .../drawable-xhdpi/ic_launcher_foreground.png | Bin 7314 -> 0 bytes .../ic_launcher_foreground.png | Bin 12176 -> 0 bytes .../ic_launcher_foreground.png | Bin 17602 -> 0 bytes .../res/drawable/ic_launcher_foreground.xml | 23 +++++++ .../res/drawable/ic_launcher_monochrome.xml | 12 ++++ .../app/src/main/res/drawable/splash_icon.xml | 3 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 12 +--- .../mipmap-hdpi/ic_launcher_monochrome.png | Bin 1811 -> 0 bytes .../mipmap-mdpi/ic_launcher_monochrome.png | Bin 1131 -> 0 bytes .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin 2527 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 4290 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 6227 -> 0 bytes .../src/main/res/values-night-v31/styles.xml | 39 +++++++++++ .../app/src/main/res/values-night/colors.xml | 4 ++ .../app/src/main/res/values-night/styles.xml | 25 ++++++- .../app/src/main/res/values-v31/styles.xml | 39 +++++++++++ android/app/src/main/res/values/colors.xml | 4 ++ android/app/src/main/res/values/styles.xml | 25 ++++++- assets/plezy_adaptive_foreground.svg | 1 + assets/plezy_android_foreground.png | Bin 60509 -> 0 bytes assets/plezy_monochrome.png | Bin 21736 -> 0 bytes lib/i18n/de.i18n.json | 7 +- lib/i18n/en.i18n.json | 7 +- lib/i18n/es.i18n.json | 7 +- lib/i18n/fr.i18n.json | 7 +- lib/i18n/it.i18n.json | 7 +- lib/i18n/ko.i18n.json | 7 +- lib/i18n/nl.i18n.json | 7 +- lib/i18n/strings_de.g.dart | 10 +++ lib/i18n/strings_en.g.dart | 20 ++++++ lib/i18n/strings_es.g.dart | 10 +++ lib/i18n/strings_fr.g.dart | 10 +++ lib/i18n/strings_it.g.dart | 10 +++ lib/i18n/strings_ko.g.dart | 10 +++ lib/i18n/strings_nl.g.dart | 10 +++ lib/i18n/strings_sv.g.dart | 10 +++ lib/i18n/strings_zh.g.dart | 10 +++ lib/i18n/sv.i18n.json | 7 +- lib/i18n/zh.i18n.json | 7 +- lib/main.dart | 64 ++++++++++++++---- lib/providers/theme_provider.dart | 17 +++++ lib/screens/auth_screen.dart | 3 +- lib/utils/navigation_transitions.dart | 12 ++++ pubspec.lock | 32 --------- pubspec.yaml | 16 +---- 48 files changed, 472 insertions(+), 81 deletions(-) delete mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png delete mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png delete mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png delete mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png delete mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/drawable/splash_icon.xml delete mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png delete mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png delete mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png delete mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png delete mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 assets/plezy_adaptive_foreground.svg delete mode 100644 assets/plezy_android_foreground.png delete mode 100644 assets/plezy_monochrome.png create mode 100644 lib/utils/navigation_transitions.dart 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 c23b5ff7..4ef3e3b2 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -26,12 +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) { @@ -153,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/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 2727eef3758f5fea7e4f04ad7d2bf8dd9b7d84a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5248 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 I|Mt_r08rZchX4Qo 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 313637563aba08a445af7e9b6ba1a3ecdf8e6fc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3220 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 W(haYQuakZOl4+^yt5vJmefS@5v?}oc 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 4ccdb0b78b603147a6593fb3f71f54699bc01453..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12176 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 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 cee25601dbfeebb6b493fa1307926234d4f20cc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17602 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 + + + + + + + + + + + + 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 823e1e798395385bd5c6f0677b3bbc61337e3b79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1811 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 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 6d4c10256292f2f9c3778c52e4f0acefb171a148..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2527 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 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 5e610a3cb5682f74d7a82de44f9f13c402e352cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4290 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 ZoAM=y3$U9KYTZ_B7tT4`RoS5b`d^@it + + + + + + + + + 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/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 68bda7f95cf8ac389af9dcf1fe711715cfecb5fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60509 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 u3+<-Fx3!X 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 E0zoh0D*ylh diff --git a/assets/plezy_monochrome.png b/assets/plezy_monochrome.png deleted file mode 100644 index 5e50d5bf5b1106f59b61027bd48c4457906e2f1c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21736 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 Cru&Hi diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index f4fb1154..fdfbba20 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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index dd069605..cbc18e9e 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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 3440384f..7e80021f 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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 585b23e0..9a0f1315 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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 9c787968..b1da2926 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", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index fa95896b..1fcca5ef 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": "라이선스", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 6269ccd2..e08757ae 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", diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 8a362f19..e89a84e9 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 2da53df6..285c991e 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 @@ -2930,6 +2945,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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 6d5f756c..2b36a0dd 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 53dab1ac..79dac6a8 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 20ea62c6..96039f0c 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index c34fb3f1..891b5017 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 @@ -1338,6 +1343,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' => '자막 스타일 설정', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index da12c779..fc2ca405 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index b7d2868b..735b8ab4 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 @@ -1338,6 +1343,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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 37a6fff0..013cfa06 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 @@ -1338,6 +1343,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' => '字幕样式', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 2f52a17e..9a0761e9 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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 3453e72a..5b08f62c 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": "许可证", diff --git a/lib/main.dart b/lib/main.dart index 84820f9a..7f1cebd4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ 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'; @@ -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. @@ -368,13 +370,21 @@ 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); @@ -386,6 +396,8 @@ class _SetupScreenState extends State { final hasNetwork = !connectivityResult.contains(ConnectivityResult.none); if (hasNetwork) { + _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. @@ -393,18 +405,20 @@ class _SetupScreenState extends State { if (refreshResult == ServerRefreshResult.authError) { await storage.clearCredentials(); if (mounted) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const AuthScreen())); + 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; } @@ -413,15 +427,18 @@ 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)), + fadeRoute(const MainScreen(isOfflineMode: true)), ); return; } + _setStatus(t.common.connectingToServers); + try { final result = await ServerConnectionOrchestrator.connectAndInitialize( servers: servers, @@ -442,25 +459,27 @@ class _SetupScreenState extends State { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)), + 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)), + 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)), + fadeRoute(const MainScreen(isOfflineMode: true)), ); } } @@ -468,12 +487,33 @@ class _SetupScreenState extends State { @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/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..508b0ad1 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 { @@ -114,7 +115,7 @@ class _AuthScreenState extends State { if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)), + fadeRoute(MainScreen(client: result.firstClient!)), ); } catch (e) { appLogger.e('Failed to connect to servers', error: e); 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/pubspec.lock b/pubspec.lock index 99582c4e..d014e342 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: @@ -488,14 +480,6 @@ packages: 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: @@ -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: @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 0acf7863..8f6e0d13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,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 +80,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 From a56ef0e3ca6f4e60c413eda53534bf214d68fefc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:28:30 +0100 Subject: [PATCH 33/64] fix: throttle cache state parsing --- lib/mpv/player/player_base.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 82a06537..a3270125 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -40,6 +40,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { bool _disposed = false; final _throttleSw = Stopwatch()..start(); int _lastEmitMs = 0; + int _lastCacheStateMs = 0; int _positionMs = 0; int _nextPropId = 0; final Map _propIdToName = {}; @@ -274,6 +275,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; From 5cf5b2f0a0b15623f17a5fcb1aeab61eef9b1211 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:56:02 +0100 Subject: [PATCH 34/64] fix: mpv destroy/create ANR --- .../plezy/exoplayer/ExoPlayerPlugin.kt | 110 +++++++++--------- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 60 ++++++---- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 14 +-- 3 files changed, 100 insertions(+), 84 deletions(-) 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..5bd490ac 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 @@ -585,63 +585,63 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, 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) { + Log.e(TAG, "Failed to initialize MPV fallback") + onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) + return@initialize } + + 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()) + } + } + + // 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) { 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..158af551 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 @@ -178,10 +181,11 @@ 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 { @@ -250,25 +254,31 @@ class MpvPlayerCore(private val activity: Activity) : Log.d(TAG, "SurfaceView added to content view") - // Initialize MPVLib - MPVLib.create(activity.applicationContext) - - // Configure MPV defaults - setupMpvDefaults() - - // Initialize MPV - MPVLib.init() - - // Register event and log observers - MPVLib.addObserver(this) - MPVLib.addLogObserver(this) - - isInitialized = true - Log.d(TAG, "Initialized successfully") - return true + // 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) { + MPVLib.create(ctx) + setupMpvDefaults() + MPVLib.init() + } + handler.post { + MPVLib.addObserver(this) + MPVLib.addLogObserver(this) + isInitialized = true + Log.d(TAG, "Initialized successfully") + onResult(true) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize native: ${e.message}", e) + handler.post { onResult(false) } + } + }.start() } catch (e: Exception) { Log.e(TAG, "Failed to initialize: ${e.message}", e) - return false + onResult(false) } } @@ -737,10 +747,16 @@ class MpvPlayerCore(private val activity: Activity) : } surfaceContainer = null surfaceView = null - - MPVLib.destroy() 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. + Thread { + synchronized(mpvLock) { + MPVLib.destroy() + } + 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) From 2b5923d80b14a1fc48682bb94d25aa0771153335 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:00:55 +0100 Subject: [PATCH 35/64] feat: square art in hero sections --- lib/models/plex_metadata.dart | 32 +++++++++++++++++----- lib/models/plex_metadata.g.dart | 2 ++ lib/screens/discover_screen.dart | 9 +++--- lib/screens/media_detail_screen.dart | 9 ++++-- lib/services/download_manager_service.dart | 10 +++++++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 25e45c20..cf9347a1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -112,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; @@ -186,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 @@ -242,6 +246,7 @@ class PlexMetadata with MultiServerFields { String? serverId, String? serverName, String? clearLogo, + String? backgroundSquare, }) { return PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -296,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 eb2a598e..d934f63b 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -57,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) => { @@ -110,4 +111,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => } }, 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 +1380,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 +1406,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 +1429,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, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0f357a37..48033a14 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1853,14 +1853,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 +1882,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/services/download_manager_service.dart b/lib/services/download_manager_service.dart index e0119170..cff9e81b 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -916,6 +916,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); @@ -977,6 +982,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 From e489a97847cf03a8d4ecff03a8d43dcc766b4c86 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:12:25 +0100 Subject: [PATCH 36/64] fix: throttle demuxer-cache-time updates --- lib/mpv/player/player_base.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index a3270125..a7d10d36 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -174,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); From 4588457319ff4c64b305d55f5981495eac0e2018 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:19:01 +0100 Subject: [PATCH 37/64] refactor: consolidate files --- android/fastlane/Fastfile | 3 +- lib/main.dart | 4 +- .../companion_remote/remote_command.dart | 54 +++++++++- .../companion_remote/remote_command_type.dart | 53 --------- .../companion_remote/trusted_device.dart | 54 ---------- .../companion_remote/trusted_device.g.dart | 25 ----- .../player/{ => platform}/player_android.dart | 0 lib/mpv/player/player.dart | 2 +- lib/providers/companion_remote_provider.dart | 101 +----------------- .../mobile_remote_screen.dart | 2 +- .../libraries}/alpha_jump_bar.dart | 2 +- .../libraries}/alpha_jump_helper.dart | 4 +- .../libraries}/alpha_scroll_handle.dart | 2 +- .../libraries/tabs/library_browse_tab.dart | 6 +- lib/screens/settings/settings_screen.dart | 2 +- lib/screens/video_player_screen.dart | 4 +- .../companion_remote_peer_service.dart | 1 - .../companion_remote_receiver.dart | 1 - lib/services/fullscreen_window_delegate.dart | 17 --- lib/services/macos_titlebar_service.dart | 25 ----- lib/services/macos_window_delegate.dart | 20 ---- lib/services/macos_window_service.dart | 53 ++++++++- .../performance_stats_service.dart | 2 +- 23 files changed, 123 insertions(+), 314 deletions(-) delete mode 100644 lib/models/companion_remote/remote_command_type.dart delete mode 100644 lib/models/companion_remote/trusted_device.dart delete mode 100644 lib/models/companion_remote/trusted_device.g.dart rename lib/mpv/player/{ => platform}/player_android.dart (100%) rename lib/{widgets => screens/libraries}/alpha_jump_bar.dart (99%) rename lib/{widgets => screens/libraries}/alpha_jump_helper.dart (97%) rename lib/{widgets => screens/libraries}/alpha_scroll_handle.dart (99%) delete mode 100644 lib/services/fullscreen_window_delegate.dart delete mode 100644 lib/services/macos_titlebar_service.dart delete mode 100644 lib/services/macos_window_delegate.dart diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index f09126bb..ee8ffa93 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -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/lib/main.dart b/lib/main.dart index 7f1cebd4..dec72530 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ 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'; @@ -100,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()); 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/mpv/player/player_android.dart b/lib/mpv/player/platform/player_android.dart similarity index 100% rename from lib/mpv/player/player_android.dart rename to lib/mpv/player/platform/player_android.dart diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 8bd629f8..4c512d18 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -1,7 +1,7 @@ import 'dart:io' show Platform; import '../models.dart'; -import 'player_android.dart'; +import 'platform/player_android.dart'; import 'player_native.dart'; import 'player_state.dart'; import 'player_streams.dart'; diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 3e9f5107..f7c2f9a0 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -1,31 +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 '../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; String _deviceName = 'Unknown Device'; String _platform = 'unknown'; - final List _trustedDevices = []; 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; @@ -44,7 +36,6 @@ class CompanionRemoteProvider with ChangeNotifier { StreamSubscription? _statusSubscription; CommandReceivedCallback? onCommandReceived; - DeviceApprovalCallback? onDeviceApprovalRequired; bool get isInSession => _session != null && _session!.status != RemoteSessionStatus.disconnected; bool get isHost => _session?.isHost ?? false; @@ -55,12 +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); bool get isPlayerActive => _isPlayerActive; CompanionRemoteProvider() { _initializeDeviceInfo(); - _loadTrustedDevices(); } Future _initializeDeviceInfo() async { @@ -117,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((_) { @@ -375,92 +362,6 @@ 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); - } - @override void dispose() { _reconnectTimer?.cancel(); diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 677b70b7..5c51dae4 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'; 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/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 276975e9..a4887a1b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -14,9 +14,9 @@ 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'; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index b2629ff8..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'; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 7c4899f4..bba46692 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -11,7 +11,7 @@ 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/plex_client.dart'; import '../models/livetv_channel.dart'; @@ -24,7 +24,7 @@ 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'; 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/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/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/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'; From e9218e0a3d5e9f9496e121daf4f1d48fe9725b7d Mon Sep 17 00:00:00 2001 From: Matt Vogel Date: Thu, 26 Feb 2026 12:37:17 +0000 Subject: [PATCH 38/64] Fix duplicate Plex notifications and Flutter app name on login Connection probe requests in testConnectionWithLatency were sent without X-Plex-Client-Identifier, X-Plex-Product, or X-Plex-Device-Name headers. Plex treated each anonymous probe as a new unknown device and fired a "New Device" notification for every server tested (one per shared/owned server), while displaying "Flutter" as the device name from the HTTP user-agent. Pass clientIdentifier through findBestWorkingConnection and all connection test helpers so every probe request identifies itself as "Plezy" with the persistent client UUID. This prevents spurious notifications and ensures the device shows the correct app name in Plex's device list. https://claude.ai/code/session_01V5VraujkNmk5GGPLyZ33fN --- lib/services/multi_server_manager.dart | 4 ++-- lib/services/plex_auth_service.dart | 14 ++++++++------ lib/services/plex_client.dart | 21 +++++++++++++++++++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 9afd4282..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'); @@ -388,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/plex_auth_service.dart b/lib/services/plex_auth_service.dart index ef715099..574d3151 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -392,7 +392,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 +438,7 @@ class PlexServer { cachedCandidate.url, accessToken, timeout: preferredTimeout, + clientIdentifier: clientIdentifier, ); if (result.success) { @@ -457,7 +458,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 +503,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 +525,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 +549,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 +667,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 +717,7 @@ class PlexServer { httpsUrl, accessToken, timeout: ConnectionTimeouts.connectionRace, + clientIdentifier: clientIdentifier, ); if (!result.success) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7aac83c8..e9ebd435 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -215,6 +215,8 @@ class PlexClient { String baseUrl, String token, { Duration timeout = const Duration(seconds: 5), + String? clientIdentifier, + String appName = 'Plezy', }) async { final stopwatch = Stopwatch()..start(); @@ -230,7 +232,14 @@ 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'] = appName; + headers['X-Plex-Device-Name'] = appName; + } + + final response = await dio.get('/', options: Options(headers: headers)); stopwatch.stop(); final success = response.statusCode == 200; @@ -266,11 +275,19 @@ class PlexClient { String token, { int attempts = 3, Duration timeout = const Duration(seconds: 5), + String? clientIdentifier, + String appName = 'Plezy', }) 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, + appName: appName, + ); // If any attempt fails, return failed result immediately if (!result.success) { From 6eeb484c30778ebad034a18f7729db5efcbda8c7 Mon Sep 17 00:00:00 2001 From: Matt Vogel Date: Thu, 26 Feb 2026 08:15:12 -0500 Subject: [PATCH 39/64] Pass persistent client UUID through auth screen login path The auth screen was calling connectAndInitialize without a clientIdentifier, causing MultiServerManager to fall back to a throwaway timestamp ID for all probe requests during first login. This meant Plex registered a new device on every login, not just the first ever install. --- lib/screens/auth_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 508b0ad1..6092ff55 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -97,6 +97,7 @@ class _AuthScreenState extends State { multiServerProvider: context.read(), librariesProvider: context.read(), syncService: context.read(), + clientIdentifier: _authService.clientIdentifier, ); if (!result.hasConnections) { From 7472e3a2f751d6964e6ba7f59e98f59ee7717452 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:55:11 +0100 Subject: [PATCH 40/64] fix: broken imports --- lib/mpv/player/platform/player_android.dart | 4 ++-- pubspec.lock | 4 ++-- pubspec.yaml | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 3ee3dc4d..182e1d91 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -1,7 +1,7 @@ import 'package:flutter/services.dart'; -import '../models.dart'; -import 'player_base.dart'; +import '../../models.dart'; +import '../player_base.dart'; /// Android implementation of [Player] using ExoPlayer. /// Provides hardware-accelerated playback with ASS subtitle support via libass-android. diff --git a/pubspec.lock b/pubspec.lock index d014e342..bd16bc25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -473,7 +473,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -552,7 +552,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 8f6e0d13..a511a848 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 From 602982d4269ff8039ed27137afcd9968a54b3c0d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:53:38 +0100 Subject: [PATCH 41/64] fix: remove low memory warnings during playback Closes #576 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 24 ------------------- lib/mpv/player/platform/player_android.dart | 6 ----- 2 files changed, 30 deletions(-) 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 b9747590..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 @@ -107,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 @@ -408,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() @@ -1392,9 +1371,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { abandonAudioFocus() audioManager = null - memoryCallback?.let { activity.unregisterComponentCallbacks(it) } - memoryCallback = null - tunnelingDisabledForCodec = false pendingStartPositionMs = 0L exoPlayer?.clearVideoSurface() diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 182e1d91..ec1f2250 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -36,12 +36,6 @@ class PlayerAndroid extends PlayerBase { return; } - if (name == 'memory-pressure') { - // System memory is critically low — playback may be at risk of OOM crash - errorController.add('Low memory — playback may be unstable'); - return; - } - // Delegate to base class for common events super.handlePlayerEvent(name, data); } From 732c09eec1fbc225f0acfb5164f6bc27019570df Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:11:16 +0100 Subject: [PATCH 42/64] fix: cancel status stream subscription on dispose Closes #571 --- lib/providers/multi_server_provider.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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(); } From 8559037a505c9d8aa9650044dfb7a24a1a7db1b1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:36:59 +0100 Subject: [PATCH 43/64] fix: logs screen d-pad navigation --- lib/screens/settings/logs_screen.dart | 346 ++++++++++++++------------ 1 file changed, 183 insertions(+), 163 deletions(-) diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 3f3eaa1f..6dff4316 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -7,10 +7,11 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.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,11 +22,39 @@ class LogsScreen extends StatefulWidget { class _LogsScreenState extends State { List _logs = []; + final ScrollController _scrollController = ScrollController(); + + late final FocusNode _refreshFocusNode; + late final FocusNode _uploadFocusNode; + late final FocusNode _copyFocusNode; + late final FocusNode _clearFocusNode; + bool _isRefreshFocused = false; + bool _isUploadFocused = false; + bool _isCopyFocused = false; + bool _isClearFocused = false; @override void initState() { super.initState(); _logs = MemoryLogOutput.getLogs(); + _refreshFocusNode = FocusNode(debugLabel: 'RefreshLogs'); + _uploadFocusNode = FocusNode(debugLabel: 'UploadLogs'); + _copyFocusNode = FocusNode(debugLabel: 'CopyLogs'); + _clearFocusNode = FocusNode(debugLabel: 'ClearLogs'); + _refreshFocusNode.addListener(() => setState(() => _isRefreshFocused = _refreshFocusNode.hasFocus)); + _uploadFocusNode.addListener(() => setState(() => _isUploadFocused = _uploadFocusNode.hasFocus)); + _copyFocusNode.addListener(() => setState(() => _isCopyFocused = _copyFocusNode.hasFocus)); + _clearFocusNode.addListener(() => setState(() => _isClearFocused = _clearFocusNode.hasFocus)); + } + + @override + void dispose() { + _scrollController.dispose(); + _refreshFocusNode.dispose(); + _uploadFocusNode.dispose(); + _copyFocusNode.dispose(); + _clearFocusNode.dispose(); + super.dispose(); } void _loadLogs() { @@ -156,178 +185,169 @@ 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; + 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; + } - 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; - - @override - Widget build(BuildContext context) { - final hasErrorOrStackTrace = widget.log.error != null || widget.log.stackTrace != null; - - 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), - ], - ), - ), - 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 _buildActionButton({ + required FocusNode focusNode, + required bool isFocused, + required FocusOnKeyEventCallback onKeyEvent, + required IconData icon, + required String? tooltip, + required VoidCallback? onPressed, + }) { + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: Container( + decoration: BoxDecoration( + color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + child: IconButton( + icon: AppIcon(icon, fill: 1), + tooltip: tooltip, + onPressed: onPressed, ), ), ); } - 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), + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + 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: [ + _buildActionButton( + focusNode: _refreshFocusNode, + isFocused: _isRefreshFocused, + onKeyEvent: dpadKeyHandler( + onSelect: _loadLogs, + onRight: () => _uploadFocusNode.requestFocus(), + ), + icon: Symbols.refresh_rounded, + tooltip: t.common.refresh, + onPressed: _loadLogs, + ), + _buildActionButton( + focusNode: _uploadFocusNode, + isFocused: _isUploadFocused, + onKeyEvent: dpadKeyHandler( + onSelect: _logs.isNotEmpty ? _uploadLogs : null, + onLeft: () => _refreshFocusNode.requestFocus(), + onRight: () => _copyFocusNode.requestFocus(), + ), + icon: Symbols.upload_rounded, + tooltip: t.logs.uploadLogs, + onPressed: _logs.isNotEmpty ? _uploadLogs : null, + ), + _buildActionButton( + focusNode: _copyFocusNode, + isFocused: _isCopyFocused, + onKeyEvent: dpadKeyHandler( + onSelect: _logs.isNotEmpty ? _copyAllLogs : null, + onLeft: () => _uploadFocusNode.requestFocus(), + onRight: () => _clearFocusNode.requestFocus(), + ), + icon: Symbols.content_copy_rounded, + tooltip: t.logs.copyLogs, + onPressed: _logs.isNotEmpty ? _copyAllLogs : null, + ), + _buildActionButton( + focusNode: _clearFocusNode, + isFocused: _isClearFocused, + onKeyEvent: dpadKeyHandler( + onSelect: _logs.isNotEmpty ? _clearLogs : null, + onLeft: () => _copyFocusNode.requestFocus(), + ), + 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(), + ), + ), + ), + ), + ], ), - 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'), - ), - ), - ], + ), ); } } From cc30bee83aa6beb2f912037845ad02ef585466a4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:55:19 +0100 Subject: [PATCH 44/64] refactor: extract FocusableActionBar widget for reusable app bar actions Replace duplicated focusable action button pattern across 8 screens with a shared FocusableActionBar widget that handles focus nodes, D-pad navigation, back key, and focus decoration automatically. --- lib/focus/focusable_action_bar.dart | 184 +++++++++ lib/screens/collection_detail_screen.dart | 29 +- lib/screens/discover_screen.dart | 372 ++++++------------ .../focusable_detail_screen_mixin.dart | 137 +------ lib/screens/hub_detail_screen.dart | 55 +-- lib/screens/libraries/libraries_screen.dart | 98 +---- lib/screens/livetv/live_tv_screen.dart | 61 +-- .../playlist/playlist_detail_screen.dart | 37 +- lib/screens/settings/logs_screen.dart | 115 ++---- 9 files changed, 407 insertions(+), 681 deletions(-) create mode 100644 lib/focus/focusable_action_bar.dart 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/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/discover_screen.dart b/lib/screens/discover_screen.dart index 629b1148..196be93b 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,37 +258,6 @@ 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( @@ -310,7 +265,7 @@ class _DiscoverScreenState extends State 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,44 +285,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() { @@ -379,14 +296,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 +758,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; @@ -916,171 +828,141 @@ 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)), + 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, ), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white, + // 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) => 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) => MobileRemoteScreen())); + } + }, + tooltip: t.companionRemote.title, + ), + 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)), + ), + ), + ), + ], + ), + ), + // 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), + SizedBox(width: 8), + Text(t.discover.switchProfile), + ], + ), + ), PopupMenuItem( - value: 'switch_profile', + value: 'logout', child: Row( children: [ - AppIcon(Symbols.people_rounded, fill: 1), + AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), - Text(t.discover.switchProfile), + Text(t.common.logout), ], ), ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [ - AppIcon(Symbols.logout_rounded, fill: 1), - SizedBox(width: 8), - Text(t.common.logout), - ], - ), - ), - ], + ], + ), ), - ), + ], ); }, ), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index d28d63b2..a2ffa77f 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -1,26 +1,14 @@ 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 +17,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 +46,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 +93,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..c9fff089 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -17,7 +17,7 @@ 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'; @@ -48,7 +48,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 +63,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 +83,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 +101,7 @@ class _HubDetailScreenState extends State with Refreshable, Gri void _navigateToAppBar() { setState(() => _isAppBarFocused = true); - _sortButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } void _handleBackFromContent() { @@ -122,24 +109,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 +284,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 +304,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/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1f0c7784..60aebdcf 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -4,9 +4,9 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.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 +127,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 +138,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 +329,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 +338,6 @@ class _LibrariesScreenState extends State _browseTabChipFocusNode.dispose(); _collectionsTabChipFocusNode.dispose(); _playlistsTabChipFocusNode.dispose(); - _editButtonFocusNode.removeListener(_onEditFocusChange); - _editButtonFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } @@ -935,13 +886,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 +993,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/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index e9dedf88..7e1e24ae 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/dpad_navigator.dart'; +import '../../focus/focusable_action_bar.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_dvr.dart'; @@ -31,9 +32,8 @@ class _LiveTvScreenState extends State 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 +47,6 @@ class _LiveTvScreenState extends State super.initState(); suppressAutoFocus = true; initTabNavigation(); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); _loadChannels(); } @@ -55,15 +54,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 +202,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 +242,7 @@ class _LiveTvScreenState extends State }); getTabChipFocusNode(newIndex).requestFocus(); } - : () => _refreshButtonFocusNode.requestFocus(), + : () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); @@ -303,20 +269,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/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/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 6dff4316..ed08666b 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -6,6 +6,7 @@ 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'; @@ -24,36 +25,15 @@ class _LogsScreenState extends State { List _logs = []; final ScrollController _scrollController = ScrollController(); - late final FocusNode _refreshFocusNode; - late final FocusNode _uploadFocusNode; - late final FocusNode _copyFocusNode; - late final FocusNode _clearFocusNode; - bool _isRefreshFocused = false; - bool _isUploadFocused = false; - bool _isCopyFocused = false; - bool _isClearFocused = false; - @override void initState() { super.initState(); _logs = MemoryLogOutput.getLogs(); - _refreshFocusNode = FocusNode(debugLabel: 'RefreshLogs'); - _uploadFocusNode = FocusNode(debugLabel: 'UploadLogs'); - _copyFocusNode = FocusNode(debugLabel: 'CopyLogs'); - _clearFocusNode = FocusNode(debugLabel: 'ClearLogs'); - _refreshFocusNode.addListener(() => setState(() => _isRefreshFocused = _refreshFocusNode.hasFocus)); - _uploadFocusNode.addListener(() => setState(() => _isUploadFocused = _uploadFocusNode.hasFocus)); - _copyFocusNode.addListener(() => setState(() => _isCopyFocused = _copyFocusNode.hasFocus)); - _clearFocusNode.addListener(() => setState(() => _isClearFocused = _clearFocusNode.hasFocus)); } @override void dispose() { _scrollController.dispose(); - _refreshFocusNode.dispose(); - _uploadFocusNode.dispose(); - _copyFocusNode.dispose(); - _clearFocusNode.dispose(); super.dispose(); } @@ -225,31 +205,6 @@ class _LogsScreenState extends State { return spans; } - Widget _buildActionButton({ - required FocusNode focusNode, - required bool isFocused, - required FocusOnKeyEventCallback onKeyEvent, - required IconData icon, - required String? tooltip, - required VoidCallback? onPressed, - }) { - return Focus( - focusNode: focusNode, - onKeyEvent: onKeyEvent, - child: Container( - decoration: BoxDecoration( - color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: AppIcon(icon, fill: 1), - tooltip: tooltip, - onPressed: onPressed, - ), - ), - ); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -279,51 +234,29 @@ class _LogsScreenState extends State { title: Text(t.screens.logs), pinned: true, actions: [ - _buildActionButton( - focusNode: _refreshFocusNode, - isFocused: _isRefreshFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _loadLogs, - onRight: () => _uploadFocusNode.requestFocus(), - ), - icon: Symbols.refresh_rounded, - tooltip: t.common.refresh, - onPressed: _loadLogs, - ), - _buildActionButton( - focusNode: _uploadFocusNode, - isFocused: _isUploadFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _uploadLogs : null, - onLeft: () => _refreshFocusNode.requestFocus(), - onRight: () => _copyFocusNode.requestFocus(), - ), - icon: Symbols.upload_rounded, - tooltip: t.logs.uploadLogs, - onPressed: _logs.isNotEmpty ? _uploadLogs : null, - ), - _buildActionButton( - focusNode: _copyFocusNode, - isFocused: _isCopyFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _copyAllLogs : null, - onLeft: () => _uploadFocusNode.requestFocus(), - onRight: () => _clearFocusNode.requestFocus(), - ), - icon: Symbols.content_copy_rounded, - tooltip: t.logs.copyLogs, - onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - ), - _buildActionButton( - focusNode: _clearFocusNode, - isFocused: _isClearFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _clearLogs : null, - onLeft: () => _copyFocusNode.requestFocus(), - ), - icon: Symbols.delete_outline_rounded, - tooltip: t.logs.clearLogs, - onPressed: _logs.isNotEmpty ? _clearLogs : null, + 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, + ), + ], ), ], ), From 7f03c157799944baa9c085e577bf99a0309021d0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:53:21 +0100 Subject: [PATCH 45/64] fix: restore FocusTheme import in libraries screen --- lib/screens/libraries/libraries_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 60aebdcf..f6515573 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -4,6 +4,7 @@ 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'; From ad9556e48434f5112e3667906d868fddf78c5b0e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:27:10 +0100 Subject: [PATCH 46/64] ci: add SonarQube workflow --- .github/workflows/sonar.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/sonar.yml diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 00000000..20fa9fc2 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,18 @@ +name: SonarQube + +on: + workflow_dispatch: + +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 045295b4c1e7a7b2631d7c186152df343ec56a72 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:29:36 +0100 Subject: [PATCH 47/64] ci: add sonar project properties --- sonar-project.properties | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..affbca99 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=edde746_plezy +sonar.organization=edde746 +sonar.sources=lib From b100c356fc66879e7e53b70868c24895ac996371 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:33:47 +0100 Subject: [PATCH 48/64] ci: add flutter pub get to sonar workflow --- .github/workflows/sonar.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 20fa9fc2..e47a619e 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -12,6 +12,15 @@ jobs: 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: From b5494322509968646b60e3f19bf427cfedd296e2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:34:06 +0100 Subject: [PATCH 49/64] ci: exclude generated .g.dart files from sonar --- sonar-project.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/sonar-project.properties b/sonar-project.properties index affbca99..fb6c4863 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,4 @@ sonar.projectKey=edde746_plezy sonar.organization=edde746 sonar.sources=lib +sonar.exclusions=**/*.g.dart From 9e0c3e7b685c94496aa5fe9a940b926e16e1ebac Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:49:47 +0100 Subject: [PATCH 50/64] fix: improve delete-from-server button safety Rename "Delete" to "Delete from server" with distinct icon and always-red styling to prevent accidental server deletions. Closes #568 --- lib/i18n/de.i18n.json | 5 +++-- lib/i18n/en.i18n.json | 5 +++-- lib/i18n/es.i18n.json | 5 +++-- lib/i18n/fr.i18n.json | 5 +++-- lib/i18n/it.i18n.json | 5 +++-- lib/i18n/ko.i18n.json | 5 +++-- lib/i18n/nl.i18n.json | 5 +++-- lib/i18n/strings_de.g.dart | 10 ++++++---- lib/i18n/strings_en.g.dart | 16 ++++++++++------ lib/i18n/strings_es.g.dart | 10 ++++++---- lib/i18n/strings_fr.g.dart | 10 ++++++---- lib/i18n/strings_it.g.dart | 10 ++++++---- lib/i18n/strings_ko.g.dart | 10 ++++++---- lib/i18n/strings_nl.g.dart | 10 ++++++---- lib/i18n/strings_sv.g.dart | 10 ++++++---- lib/i18n/strings_zh.g.dart | 10 ++++++---- lib/i18n/sv.i18n.json | 5 +++-- lib/i18n/zh.i18n.json | 5 +++-- lib/utils/dialogs.dart | 4 ++-- lib/widgets/focusable_list_tile.dart | 10 ++++++++++ lib/widgets/media_context_menu.dart | 17 ++++++++++++----- 21 files changed, 109 insertions(+), 63 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index fdfbba20..310e3143 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -273,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" diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index cbc18e9e..eb938889 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -273,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" diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 7e80021f..bfe8e54c 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -273,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" diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 9a0f1315..b81f1c23 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -273,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" diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index b1da2926..8e9f5558 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -273,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" diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 1fcca5ef..0450b81b 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -273,8 +273,9 @@ "goToSeason": "시즌으로 이동", "shufflePlay": "무작위 재생", "fileInfo": "파일 정보", - "confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?", - "deleteMultipleWarning": "여러 항목이 삭제될 수 있습니다.", + "deleteFromServer": "서버에서 삭제", + "confirmDelete": "이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "deleteMultipleWarning": "모든 에피소드와 파일이 포함됩니다.", "mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다", "mediaFailedToDelete": "미디어 항목 삭제 실패", "rate": "평가" diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index e08757ae..3ff7e563 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -273,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" diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index e89a84e9..55049c72 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 285c991e..09a1d73d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -876,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'; @@ -3147,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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 2b36a0dd..4824a5ea 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 79dac6a8..45e2a918 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 96039f0c..d49f792c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 891b5017..24936b1e 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -407,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 => '평가'; @@ -1545,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' => '평가', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index fc2ca405..d56dd4b5 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 735b8ab4..8c1d34c5 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -407,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'; @@ -1545,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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 013cfa06..a877e844 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -407,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 => '评分'; @@ -1545,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' => '评分', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 9a0761e9..c66ac4d8 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -273,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" diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 5b08f62c..3349eb43 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -273,8 +273,9 @@ "goToSeason": "转到季", "shufflePlay": "随机播放", "fileInfo": "文件信息", - "confirmDelete": "确定要从文件系统中删除此项吗?", - "deleteMultipleWarning": "可能会删除多个项目。", + "deleteFromServer": "从服务器删除", + "confirmDelete": "这将永久删除此媒体及其文件。此操作无法撤销。", + "deleteMultipleWarning": "这包括所有剧集及其文件。", "mediaDeletedSuccessfully": "媒体项已成功删除", "mediaFailedToDelete": "删除媒体项失败", "rate": "评分" 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/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_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(), ), From 1cdf1890cf13d6ee25e941b6b10d1b6c4b3a5263 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:56:48 +0100 Subject: [PATCH 51/64] fix: download playback issues (doubled path, audio language, spinner) --- lib/models/plex_media_info.dart | 53 ++++++++++++++++++++ lib/screens/video_player_screen.dart | 56 +++++++++++++++------- lib/services/download_storage_service.dart | 16 ++++--- 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 5b32221c..ab2bea74 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -15,6 +15,59 @@ 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) { + 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?, + )); + } + } + } + + return PlexMediaInfo( + videoUrl: '', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: const [], + ); + } } /// Builds a track label from parts with the standard `' · '` joiner pattern. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index bba46692..db002de2 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -13,6 +13,7 @@ import 'package:window_manager/window_manager.dart'; import '../mpv/mpv.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,6 +21,7 @@ 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'; @@ -139,7 +141,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; bool _isHandlingBack = false; - bool _hasThumbnails = false; + BifThumbnailService? _bifService; // Live TV channel navigation int _liveChannelIndex = -1; @@ -198,14 +200,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 @@ -1026,17 +1021,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(); } }); } @@ -1094,10 +1093,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())); } } @@ -1142,10 +1143,28 @@ 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); + } + } + } 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, ); @@ -1604,6 +1623,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(); @@ -2505,9 +2527,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/download_storage_service.dart b/lib/services/download_storage_service.dart index 4ca77b81..7bf3573c 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -369,15 +369,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; From 11377362cb8d9faf6140d1f05236e897569b5b7c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:22:39 +0100 Subject: [PATCH 52/64] fix: duplicate hero tag error in logs upload dialog --- lib/screens/settings/logs_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index ed08666b..8c83ccce 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -124,7 +124,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); }, ), ], From edb285a279e01954b51e58ea961c460032ec0322 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:49 +0100 Subject: [PATCH 53/64] feat: cache BIF file for instant preview thumbnails close #565 --- lib/services/bif_thumbnail_service.dart | 111 ++++++++++++++++++ lib/services/plex_client.dart | 20 ++-- .../desktop_video_controls.dart | 8 +- .../video_controls/mobile_video_controls.dart | 10 +- .../video_controls/video_controls.dart | 15 +-- .../widgets/timeline_slider.dart | 32 ++--- .../widgets/video_timeline_bar.dart | 10 +- 7 files changed, 160 insertions(+), 46 deletions(-) create mode 100644 lib/services/bif_thumbnail_service.dart 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/plex_client.dart b/lib/services/plex_client.dart index 7aac83c8..6d3d4705 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'; @@ -918,17 +919,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; } } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 74880b43..a74fc2b2 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -81,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; @@ -146,7 +146,7 @@ class DesktopVideoControls extends StatefulWidget { this.hasFirstFrame, this.shaderService, this.onShaderChanged, - this.thumbnailUrlBuilder, + this.thumbnailDataBuilder, this.isLive = false, this.liveChannelName, this.isAmbientLightingEnabled = false, @@ -486,7 +486,7 @@ class DesktopVideoControlsState extends State { onKeyEvent: _handleTimelineKeyEvent, onFocusChange: _onFocusChange, enabled: canInteract, - thumbnailUrlBuilder: widget.thumbnailUrlBuilder, + thumbnailDataBuilder: widget.thumbnailDataBuilder, ), const SizedBox(height: 4), ], diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index a98e6fff..2973f989 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, }); @@ -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/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index d4a0623f..b5853d7b 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'; @@ -77,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, @@ -103,7 +104,7 @@ Widget plexVideoControlsBuilder( controlsVisible: controlsVisible, shaderService: shaderService, onShaderChanged: onShaderChanged, - thumbnailUrlBuilder: thumbnailUrlBuilder, + thumbnailDataBuilder: thumbnailDataBuilder, isLive: isLive, liveChannelName: liveChannelName, isAmbientLightingEnabled: isAmbientLightingEnabled, @@ -148,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; @@ -184,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, @@ -1853,7 +1854,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, ), @@ -1977,7 +1978,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/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index ec251858..c2362809 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); 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, ); } } From ab2f8cf6ff50efc1a2eddba3e557a868d0090db2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:39:17 +0100 Subject: [PATCH 54/64] fix: normalize corrupted download paths on startup --- lib/services/download_manager_service.dart | 26 +++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index cff9e81b..4e6ac70e 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -309,9 +309,33 @@ 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. + final prefs = (await SettingsService.getInstance()).prefs; + if (!(prefs.getBool('download_paths_normalized') ?? false)) { + final allItems = await _database.select(_database.downloadedMedia).get(); + var fixed = 0; + for (final item in allItems) { + if (item.videoFilePath != null) { + final normalized = await _storageService.toRelativePath(item.videoFilePath!); + if (normalized != item.videoFilePath) { + await _database.updateVideoFilePath(item.globalKey, normalized); + fixed++; + } + } + if (item.thumbPath != null) { + final normalized = await _storageService.toRelativePath(item.thumbPath!); + if (normalized != item.thumbPath) { + await _database.updateArtworkPaths(globalKey: item.globalKey, thumbPath: normalized); + } + } + } + if (fixed > 0) appLogger.i('Normalized $fixed corrupted download path(s)'); + await prefs.setBool('download_paths_normalized', true); + } + // 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 From 00667fe8b87c979944eecda4b79d1322b9535160 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:42:12 +0100 Subject: [PATCH 55/64] fix: add const constructors and remove unused imports --- lib/main.dart | 41 ++--- lib/screens/auth_screen.dart | 7 +- .../mobile_remote_screen.dart | 4 +- lib/screens/discover_screen.dart | 58 ++++--- .../focusable_detail_screen_mixin.dart | 1 - lib/screens/hub_detail_screen.dart | 4 - lib/screens/livetv/live_tv_screen.dart | 1 - lib/screens/livetv/tabs/guide_tab.dart | 2 +- lib/screens/profile/pin_entry_dialog.dart | 8 +- lib/screens/search_screen.dart | 12 +- lib/screens/season_detail_screen.dart | 11 +- lib/screens/settings/logs_screen.dart | 1 - lib/services/plex_auth_service.dart | 13 +- lib/services/settings_service.dart | 42 ++--- lib/theme/mono_theme.dart | 2 +- .../desktop_video_controls.dart | 2 +- .../video_controls/mobile_video_controls.dart | 2 +- .../video_controls/sheets/chapter_sheet.dart | 159 +++++++++--------- .../video_controls/sheets/queue_sheet.dart | 16 +- .../video_controls/video_controls.dart | 6 +- .../widgets/timeline_slider.dart | 34 ++-- 21 files changed, 206 insertions(+), 220 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index dec72530..8dd3fb54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -137,7 +137,7 @@ void main() async { void _registerShaderLicenses() { LicenseRegistry.addLicense(() async* { - yield LicenseEntryWithLineBreaks( + yield const LicenseEntryWithLineBreaks( ['Anime4K'], 'MIT License\n' '\n' @@ -162,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' @@ -390,9 +390,10 @@ class _SetupScreenState extends State { // 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 connectivityResult = await Connectivity().checkConnectivity().timeout( + const Duration(seconds: 3), + onTimeout: () => [ConnectivityResult.other], + ); final hasNetwork = !connectivityResult.contains(ConnectivityResult.none); if (hasNetwork) { @@ -430,10 +431,7 @@ class _SetupScreenState extends State { _setStatus(t.common.startingOfflineMode); await context.read().ensureInitialized(); if (!mounted) return; - Navigator.pushReplacement( - context, - fadeRoute(const MainScreen(isOfflineMode: true)), - ); + Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))); return; } @@ -457,18 +455,12 @@ class _SetupScreenState extends State { downloadProvider.resumeQueuedDownloads(result.firstClient!); }); - Navigator.pushReplacement( - context, - fadeRoute(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, - fadeRoute(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); @@ -477,10 +469,7 @@ class _SetupScreenState extends State { _setStatus(t.common.startingOfflineMode); await context.read().ensureInitialized(); if (!mounted) return; - Navigator.pushReplacement( - context, - fadeRoute(const MainScreen(isOfflineMode: true)), - ); + Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))); } } } @@ -493,9 +482,7 @@ class _SetupScreenState extends State { 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), - ), + Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)), // Status text below center, independent of icon position. Positioned( left: 0, @@ -507,9 +494,9 @@ class _SetupScreenState extends State { _statusMessage, key: ValueKey(_statusMessage), textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), ), ), ), diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 508b0ad1..dd9ba986 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -113,10 +113,7 @@ class _AuthScreenState extends State { await profileFuture; if (!mounted) return; - Navigator.pushReplacement( - context, - fadeRoute(MainScreen(client: result.firstClient!)), - ); + Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!))); } catch (e) { appLogger.e('Failed to connect to servers', error: e); setState(() { @@ -455,7 +452,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/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 5c51dae4..8d3db5d7 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.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/discover_screen.dart b/lib/screens/discover_screen.dart index 196be93b..e0ca4882 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -258,7 +258,6 @@ class _DiscoverScreenState extends State _loadContent(); } - /// Handle key events for the hero section late final _handleHeroKeyEvent = dpadKeyHandler( onDown: () { @@ -285,7 +284,6 @@ class _DiscoverScreenState extends State }, ); - @override void dispose() { _hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged); @@ -781,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) { @@ -838,14 +842,11 @@ class _DiscoverScreenState extends State onNavigateLeft: _navigateToSidebar, onNavigateDown: _focusContentFromAppBar, actions: [ - FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: Colors.white, - onPressed: _loadContent, - ), + FocusableAction(icon: Symbols.refresh_rounded, iconColor: Colors.white, onPressed: _loadContent), // Watch Together FocusableAction( - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), child: Stack( children: [ IconButton( @@ -854,7 +855,10 @@ class _DiscoverScreenState extends State fill: watchTogether.isInSession ? 1 : 0, color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white, ), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const WatchTogetherScreen()), + ), tooltip: 'Watch Together', ), if (watchTogether.isInSession && watchTogether.participantCount > 1) @@ -886,7 +890,10 @@ class _DiscoverScreenState extends State if (isDesktop) { RemoteSessionDialog.show(context); } else { - Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), + ); } }, child: Stack( @@ -895,13 +902,18 @@ class _DiscoverScreenState extends State icon: AppIcon( Symbols.phone_android_rounded, fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white, + color: companionRemote.isConnected + ? Theme.of(context).colorScheme.primary + : Colors.white, ), onPressed: () { if (isDesktop) { RemoteSessionDialog.show(context); } else { - Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), + ); } }, tooltip: t.companionRemote.title, @@ -913,10 +925,10 @@ class _DiscoverScreenState extends State child: Container( width: 8, height: 8, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.green, shape: BoxShape.circle, - border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)), + border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)), ), ), ), @@ -944,7 +956,7 @@ class _DiscoverScreenState extends State child: Row( children: [ AppIcon(Symbols.people_rounded, fill: 1), - SizedBox(width: 8), + const SizedBox(width: 8), Text(t.discover.switchProfile), ], ), @@ -954,7 +966,7 @@ class _DiscoverScreenState extends State child: Row( children: [ AppIcon(Symbols.logout_rounded, fill: 1), - SizedBox(width: 8), + const SizedBox(width: 8), Text(t.common.logout), ], ), @@ -1097,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)), ], ), ), @@ -1575,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 a2ffa77f..d58825b6 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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'; diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index c9fff089..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/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'; diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 7e1e24ae..a16db00c 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../focus/dpad_navigator.dart'; import '../../focus/focusable_action_bar.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; 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/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 13ad8091..2fc85163 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -59,7 +59,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 @@ -364,14 +365,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() diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 8c83ccce..edbabbe5 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -2,7 +2,6 @@ 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'; diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index ef715099..e6c51f48 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; @@ -863,7 +862,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/settings_service.dart b/lib/services/settings_service.dart index 4b830db9..3fd86334 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -399,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), }; } 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/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index a74fc2b2..7b905984 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -453,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), diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 2973f989..f59584cd 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -199,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), 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/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index b5853d7b..25d998cf 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1288,7 +1288,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', @@ -1565,7 +1565,9 @@ class _PlexVideoControlsState extends State with WindowListen // 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 (!_videoPlayerNavigationEnabled && + (Platform.isWindows || Platform.isLinux) && + event.logicalKey.isBackKey) { if (event is KeyUpEvent) { _exitFullscreenIfNeeded(); } diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index c2362809..ac3ebf05 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -242,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) From c1e99118412f9b7d77c54bbb3520d321c219bea8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:41:27 +0100 Subject: [PATCH 56/64] chore: update MPVKit to 0.41.0 on iOS/macOS --- ios/Runner.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- macos/Runner.xcodeproj/project.pbxproj | 2 +- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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/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" } } ], From 5ff5441baa97cb67511c6cba38a557215248c537 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:51:34 +0100 Subject: [PATCH 57/64] fix: map ssa subtitle codec to .ass extension close #569 --- lib/utils/codec_utils.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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'; From 03d62d2e2be46be6fa5bebcffdcc80f47e7d2cfe Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:05:05 +0100 Subject: [PATCH 58/64] fix: handle corrupted download paths missing leading slash --- lib/models/plex_media_info.dart | 57 ++++++++++++---------- lib/screens/video_player_screen.dart | 2 + lib/services/download_manager_service.dart | 26 +++++++--- lib/services/download_storage_service.dart | 40 ++++++++++----- 4 files changed, 82 insertions(+), 43 deletions(-) diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index ab2bea74..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 { @@ -31,32 +32,36 @@ class PlexMediaInfo { if (streams != null) { for (final s in streams) { - 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?, - )); + 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); } } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index db002de2..ed0ea2d5 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1156,6 +1156,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin 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); diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 4e6ac70e..b73b97b7 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -311,27 +311,41 @@ class DownloadManagerService { // 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.getBool('download_paths_normalized') ?? false)) { + 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 normalized = await _storageService.toRelativePath(item.videoFilePath!); - if (normalized != item.videoFilePath) { + 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 normalized = await _storageService.toRelativePath(item.thumbPath!); - if (normalized != item.thumbPath) { + 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.setBool('download_paths_normalized', true); + await prefs.setInt('download_paths_normalized_version', 2); } // Scan drift for orphaned items stuck in 'downloading' diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 7bf3573c..1841a028 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'; @@ -398,25 +399,42 @@ 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 { + appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); + + String result; if (path.isAbsolute(storedPath)) { // Already absolute - check if file exists at this path if (await File(storedPath).exists()) { - return storedPath; + result = storedPath; + } else { + // File doesn't exist at absolute path - try to reconstruct + // Extract the relative portion (everything after 'downloads/') + final downloadsIndex = storedPath.indexOf('downloads/'); + if (downloadsIndex != -1) { + final relativePart = storedPath.substring(downloadsIndex); + result = await toAbsolutePath(relativePart); + } else { + // Can't reconstruct, return original + result = storedPath; + } } - // File doesn't exist at absolute path - try to reconstruct - // Extract the relative portion (everything after 'downloads/') + } else { + // Relative path — if it contains a nested base-dir fragment + // (e.g. "data/.../app_flutter/downloads/..."), extract from downloads/ onward final downloadsIndex = storedPath.indexOf('downloads/'); - if (downloadsIndex != -1) { - final relativePart = storedPath.substring(downloadsIndex); - return await toAbsolutePath(relativePart); + if (downloadsIndex > 0) { + result = await toAbsolutePath(storedPath.substring(downloadsIndex)); + } else { + result = await toAbsolutePath(storedPath); } - // Can't reconstruct, return original - return storedPath; } - // Relative path - convert to absolute - return await toAbsolutePath(storedPath); + + appLogger.d('ensureAbsolutePath: resolved="$result"'); + return result; } /// Calculate total storage used by downloads From 7a0a911cd9ffe53ca886eb611ae4c7a4af00a98c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:32:34 +0100 Subject: [PATCH 59/64] fix: prevent ASS subtitle stretching close #579 --- lib/screens/video_player_screen.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ed0ea2d5..5b436a85 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -421,6 +421,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 From 3103cb5ee1e2926a4ceebd60d347ebbc85157024 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:02:30 +0100 Subject: [PATCH 60/64] fix: harden offline playback and exoplayer fallback --- .../plezy/exoplayer/ExoPlayerPlugin.kt | 25 +++- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 121 +++++++++++++++--- lib/screens/video_player_screen.dart | 87 +++++++++---- lib/services/download_storage_service.dart | 85 ++++++++---- 4 files changed, 246 insertions(+), 72 deletions(-) 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 5bd490ac..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,6 +586,8 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, // Dispose ExoPlayer playerCore?.dispose() playerCore = null + mpvCore?.dispose() + mpvCore = null // Create and initialize MPV mpvCore = MpvPlayerCore(currentActivity).apply { @@ -587,12 +595,14 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } 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") @@ -643,6 +653,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, 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 158af551..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 @@ -50,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 @@ -189,6 +192,9 @@ class MpvPlayerCore(private val activity: Activity) : } try { + disposing = false + pendingSurface = null + // Initialize AudioManager for audio focus handling audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -260,19 +266,50 @@ class MpvPlayerCore(private val activity: Activity) : 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 + } + MPVLib.addObserver(this) MPVLib.addLogObserver(this) isInitialized = true + + // 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 + 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() @@ -360,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 @@ -713,6 +784,8 @@ class MpvPlayerCore(private val activity: Activity) : // Cleanup fun dispose() { + if (disposing) return + disposing = true Log.d(TAG, "Disposing") // Shutdown command executor @@ -725,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) @@ -747,16 +827,25 @@ class MpvPlayerCore(private val activity: Activity) : } surfaceContainer = null surfaceView = null + pendingSurface = null isInitialized = false // 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. - Thread { - synchronized(mpvLock) { - MPVLib.destroy() - } - Log.d(TAG, "Disposed (native)") - }.start() + 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/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 5b436a85..67a54592 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -140,6 +140,7 @@ 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; BifThumbnailService? _bifService; @@ -552,6 +553,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 && @@ -1149,16 +1156,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin try { final serverId = widget.metadata.serverId; if (serverId != null) { - final cached = await PlexApiCache.instance.get( - serverId, - '/library/metadata/${widget.metadata.ratingKey}', - ); + 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}'); + 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); @@ -2081,26 +2087,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. diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 1841a028..b3e10bf3 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -404,37 +404,68 @@ class DownloadStorageService { /// (e.g. "data/user/0/.../app_flutter/downloads/..."). Future ensureAbsolutePath(String storedPath) async { appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); + final baseDir = await _getBaseAppDir(); + final normalizedCandidates = []; - String result; - if (path.isAbsolute(storedPath)) { - // Already absolute - check if file exists at this path - if (await File(storedPath).exists()) { - result = storedPath; - } else { - // File doesn't exist at absolute path - try to reconstruct - // Extract the relative portion (everything after 'downloads/') - final downloadsIndex = storedPath.indexOf('downloads/'); - if (downloadsIndex != -1) { - final relativePart = storedPath.substring(downloadsIndex); - result = await toAbsolutePath(relativePart); - } else { - // Can't reconstruct, return original - result = storedPath; - } - } - } else { - // Relative path — if it contains a nested base-dir fragment - // (e.g. "data/.../app_flutter/downloads/..."), extract from downloads/ onward - final downloadsIndex = storedPath.indexOf('downloads/'); - if (downloadsIndex > 0) { - result = await toAbsolutePath(storedPath.substring(downloadsIndex)); - } else { - result = await toAbsolutePath(storedPath); + void addCandidate(String candidate) { + if (candidate.isEmpty) return; + final normalized = path.normalize(candidate); + if (!normalizedCandidates.contains(normalized)) { + normalizedCandidates.add(normalized); } } - appLogger.d('ensureAbsolutePath: resolved="$result"'); - return result; + 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); + 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))); + } + } + + // 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 From 4cebff436786738d1bf05f0fa38189b0941d6ee0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:19:52 +0100 Subject: [PATCH 61/64] move debug output --- .gitignore | 1 + android/fastlane/Fastfile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/android/fastlane/Fastfile b/android/fastlane/Fastfile index ee8ffa93..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") From 389a9622c3caa187d1d15b429a712185d61be625 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:23:31 +0100 Subject: [PATCH 62/64] fix: prevent OOM crash from infinite focus reclaim loop Add re-entrancy guard to VideoPlayerScreen._onScreenFocusChanged() and replace busy-wait spin loop with Completer in MediaDetailScreen. --- lib/screens/media_detail_screen.dart | 14 ++++++++++++-- lib/screens/video_player_screen.dart | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 48033a14..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; @@ -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; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 67a54592..6d2c0e90 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -173,6 +173,7 @@ 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; @@ -1723,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(); } From 18614022c0b097ebd795a6296c7b4b24cbcfaf07 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:12:47 +0100 Subject: [PATCH 63/64] feat: auto-dismiss skip segment buttons closes #584 --- .../video_controls/video_controls.dart | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 25d998cf..8bab6f36 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -246,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 @@ -355,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((_) { @@ -414,6 +425,7 @@ class _PlexVideoControlsState extends State with WindowListen widget.onSeekCompleted?.call(endTime); } _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); } void _startAutoSkipTimer(PlexMarker marker) { @@ -460,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; @@ -476,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 { @@ -570,6 +604,7 @@ class _PlexVideoControlsState extends State with WindowListen _hideTimer?.cancel(); _feedbackTimer?.cancel(); _autoSkipTimer?.cancel(); + _skipButtonDismissTimer?.cancel(); _singleTapTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); @@ -653,7 +688,12 @@ 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(); @@ -751,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 { @@ -1535,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(); } } @@ -1883,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, From 4929e7d42b149dbd0f780cf7e4adc3d3c2cc749e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:19:24 +0100 Subject: [PATCH 64/64] fix: hardcode app name in connection probe headers --- lib/services/plex_client.dart | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index e9ebd435..ad83b0bf 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -216,7 +216,6 @@ class PlexClient { String token, { Duration timeout = const Duration(seconds: 5), String? clientIdentifier, - String appName = 'Plezy', }) async { final stopwatch = Stopwatch()..start(); @@ -235,8 +234,8 @@ class PlexClient { final headers = {'X-Plex-Token': token}; if (clientIdentifier != null) { headers['X-Plex-Client-Identifier'] = clientIdentifier; - headers['X-Plex-Product'] = appName; - headers['X-Plex-Device-Name'] = appName; + headers['X-Plex-Product'] = 'Plezy'; + headers['X-Plex-Device-Name'] = 'Plezy'; } final response = await dio.get('/', options: Options(headers: headers)); @@ -276,7 +275,6 @@ class PlexClient { int attempts = 3, Duration timeout = const Duration(seconds: 5), String? clientIdentifier, - String appName = 'Plezy', }) async { final results = []; @@ -286,7 +284,6 @@ class PlexClient { token, timeout: timeout, clientIdentifier: clientIdentifier, - appName: appName, ); // If any attempt fails, return failed result immediately