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 146e1f515..5fe69e32d 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 d9017c55a..0864f22cd 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 bbcb5050c..a6a9586c3 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 2ff10169e..494a00386 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 5fe69e32d..efe4b5345 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 2428f3084..24597798e 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 cb152d010..c2028f6d7 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 ac91bb853..d10866fc7 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 117382473..fd153ea35 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 0f162f028..b2234b4c5 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 78c36cf44..4fc43ef18 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 08ba3a3fa..04315f36a 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 7ea9bc965..c23b5ff78 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 254c0e440..91564fe0d 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 6b65b4541..0546a4ad7 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 2191f7677..0eb849a12 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 09d812618..2c778ec7a 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 d17307d20..6afb3ef45 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 de81d4768..568f8682d 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 2f717b6dc..4c8a834c0 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 863716013..aa3b2248f 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 a8fe3fb6e..b65113db2 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 8c51862c8..e11c72a1d 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 2c13e9881..77e93c6ef 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 3992dfe5d..4b9b882ca 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 fa2c8f743..c6b8f1734 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 084aae4c6..5709fcb85 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 ef1658f55..c4f1e0ce3 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 d8b08f44c..4cfb93231 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 520f19926..b9370a02e 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 df3ccb2e8..867c769bc 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 efe4b5345..7c4899f40 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 cb68e9e54..518a9c678 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 9b854ecef..2c94a438f 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 d2def9fc8..08acff742 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 000000000..293a9d802 --- /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 1d51c60d2..b8d678e65 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 5c7cb83ec..1f0c77845 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 75bf54fd3..ca588ea74 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 4f6a33889..b8321fcda 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 b1ce06ad0..99582c4e8 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 b8321fcda..1dc6b04a3 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 d8a7df174..2b9eb2fbe 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 5b1301732..5b32221c0 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 0864f22cd..a46016a5a 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 ab37a94ad..d4779c15e 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 597e534e6..d053f24a5 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 08acff742..84820f9a2 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 fa23c45e3..ef1a90681 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 8e6b99d66..9afd4282c 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 c1cc4bd6c..86eb09c6c 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 954dfea33..3be80d78e 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 0c6d84c15..4ca77b818 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 a6801b6ce..25e45c204 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 cd57d744a..eb2a598e2 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 91564fe0d..b8a04f1f5 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 0546a4ad7..646a4b08e 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 0eb849a12..801739724 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 2c778ec7a..93e508af1 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 6afb3ef45..971139c94 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 568f8682d..989506d2d 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 4c8a834c0..4024209a7 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 aa3b2248f..0c096bfd2 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 b65113db2..061f0d0d2 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 e11c72a1d..824569b0c 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 77e93c6ef..c9416902b 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 4b9b882ca..ee2377a29 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 c6b8f1734..bbf1c2832 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 5709fcb85..281473fe3 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 c4f1e0ce3..8a7b13807 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 4cfb93231..a79bc4cef 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 b9370a02e..02deee9df 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 867c769bc..b82439204 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 146aa6976..000000000 --- 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 4a97dfe50..000000000 --- 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 7bb4f6cdf..3e9f51075 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 a8ac03cda..f5365d43c 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 f5136df9f..000000000 --- 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 d053f24a5..4dd252cfb 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 5c4abb48f..b0f718c5a 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 9f306562b..74880b437 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 2c94a438f..af2140c9f 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 a46016a5a..d4a0623fc 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 f18ab5fa8..7940d60bc 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 dfd00c65f..9cab3bee4 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 577d0f23a..dafe0c3a3 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 d4779c15e..e01191706 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 4dd252cfb..b97475901 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 efcd7ac59..40cb1d632 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 d10866fc7..0acf78639 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 fd153ea35..3d01fd88a 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 b8a04f1f5..f4fb11540 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 646a4b08e..dd0696050 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 801739724..3440384f2 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 93e508af1..585b23e0b 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 971139c94..9c7879687 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 989506d2d..fa95896b0 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 4024209a7..6269ccd20 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 0c096bfd2..8a362f190 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 061f0d0d2..2da53df62 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 824569b0c..6d5f756c1 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 c9416902b..53dab1acd 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 ee2377a29..20ea62c69 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 bbf1c2832..c34fb3f1e 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 281473fe3..da12c779b 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 8a7b13807..b7d2868bc 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 a79bc4cef..37a6fff01 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 02deee9df..2f52a17e5 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 b82439204..3453e72a9 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 bf3cadde3..a06943552 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 9336b8848..13ad80918 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 a7dc66a0c..b2629ff87 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 518a9c678..4b830db93 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 27618325d..a17b54817 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 2b9eb2fbe..11c3a54bd 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 9fd2313ca..0f357a37c 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 86eb09c6c..7aac83c8c 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 a93ce810e..6ede9cb64 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 c018ce4a9..82a06537f 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 8b1bac761..762d1512d 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 1dc6b04a3..276975e90 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 c23b5ff78..4ef3e3b2f 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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 b|M-Rf+j=yt&^^n*z`)??>gTe~DWM4fP}=&3 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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 diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 7d04f18d49e3760624659b9c27d370d212d658f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7314 zcmeAS@N?(olHy`uVBq!ia0y~yV7LLo9Bd2>44n$PZy6XER7+eVN>UO_QmvAUQWHy3 z8H@~!Omz(nbd3x{jEt;|EvyX9v<(cb3=IB6pPY)KAvZrIGp!Q0hF-QpO$G)A4Y&;@ znYpROC5gEOxb@r;4*15vAbr@=#WAE}&fB@1F(IM%kDs60S}T|mED+-2Dp>4Y9B-t% z`OVf#n~c7CniyYS&hEX|}L4QiN4nw8Q=Y8DxYEf zo5z?vrtbcE_os&*p1bq9=KatA|G&>Ec4c93;0<)&&&VK`q9DN_(wM*yz|zF%AjrYS z;5tGCUUn{Soq6Kyu|snOk~}4XWXhNS@pIp=Uhtmz;Fbrk6JCG%HDTU*+m~;@`Pirb zcIR|HBl#}@Xg)l-}jxnu~l8|b4IMQ^6i89;&+-I3`}oyxE@--S+`o8?^|59 zn#c`-Hi2myS~kpy<55gnc%ZUp`sXtn6a84`Q9aJm;OxGJ^5R|q`CO^yXnM# z{mwP#=fom+S9|Ap@4b@P1ndH8H5DjR=R9xKon!QSdB+$1Yi-AB=Eb-Ed6US{B7UJ2>t;WGztup)B<-hqxKMrmd}S6{Ru<)!9TCyr0yJ_q%u(Rc z->zg+c;#98tXr$wdefX5UmrUzfAjaFhIhr+TU=P@d7SNER9Um&aYKUcjcqNpKG|JY z|L~ulYV$?NJy_ADxbE5eEc>rNPCuS!F@N6v?{6;$HeO^+z4^9q!-BsJ31>IN+`pp4 zaaekbW6C1K9-T9f>&_M3{`5`vc1+!})q8F1g?rvUv{KJG`1t&r9*@5UAAHW3PrX!? zd3-K+f|r7XI%9T-#ErIZUO(qVXl$r0&UmjM_u=cD{m*=!bj@F6>_79Dh2wALgBB4_ z^>4YFT-|W-0rkm?rhG_5^)jkv$jWV zlZ=zlIDLM~=`X%=8?z2MT>Whc{}x!$SVV>`8+d4>STv4s)Kp8qon-ItnDy>aG=+_yLW-9IaTrTC)z^T+poYZ`skSa$S| zyu*fTaz1g(!g*fL+9uhWsjZv1VB^nea*w5_R$b8BCid^XzjTpGLdJRF&;0Fs1!wke z`nQ*lCELyH$n@*Y27lx?&tbQ@IOVgk?$2p`MtdJ#PKvSi-McN}(^}g=$>^qYM{8zA zYHGfpaG9-y=^)2+DM`x};m-}UWK*l0KU+?0*f`sK#qnQ}b@LeK$92W;ej4@2e`+aj zubQ3l0S^U<{U;ndR#|8zDrC0*@wvZYV`WrN_Jtpe)?$3$;�XNjV_@F*3Z@O7g%G z2Lnm3pVN|V#s*8=`1bA8B2B)_Mtj&xw{QM(|NEb0@#m_Qe|K1^{A%8@=+mBWwv7(5 z0zJ<6nm^hdY58!-=lWS#tV(!G~Aw#ftmAef%%0AmrFIQ^kw&&%>@I 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 diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..319552c73 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..d456d3be0 --- /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 000000000..35edc6c55 --- /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 6fee2a96b..93f6386e9 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 zcmeAS@N?(olHy`uVBq!ia0y~yU|0mg9Bd2>42M36Ni#68-STvC45^5FdpC29Y_Y`g zkFOSK#b$(VU8ouA>Y==F;gw5^1Upr)EmGX}sVPF}wzOzcx31DO7EXNut&Oe|Ohiv{ zZ_z)pZ)wu~@3lWG<^EgW`TTySvGMbH#qa*l`TbXAzxZ71^fbTUCDZ=f7?FRRNBuk6>$r6*WCEF%_jS2@YX-)&qsa}XYDN8_(&>m=Z^Icq<-H1 zG$FNY4^P5@H5<;o)!wwJZd*+5O^rCQ3nJRfZcdBOdc9^#zpi?u+tJgkS{s#iX=<%Y z+mkR;USC?n_2HcldsnQO^>zBwxYN@6!HDpVJIKZcE?@ ze$H+9dD+u|(;q5k8?a=4;JWy8?i_X9;zw@}2`oOSd!&b7{^x>UPbz*JxM=h`ovBH* zJZxlj&+m)H($J2X({;ODcHg|P(1iQq&!3MEPs@Fr>&73nt98c0yEz&GzglPfblEcd zt6r(UV%na&Ip=>=FF2i<(E4Kg+dY?`Rd3zbyj(Efe7FCW*Xd{@sY@ki~`f10VG@TXn?V zrrq^E+WO+KMtOwVvH7}5^KUvN^55({ye(h-ZQ5P0Uha$Setse64s7xdw=k(b_|#1Q z#(A*_yZ`kYr~b{TjDL7%1IOF6yB!0_F`PV;& zt5>Y%&rhmcRQ4@q-<;Q?cgwfgukU)M{i*F~!@tuGO;h4Kvah@^SpDHm@zieq{k!h1 z{=e(jw8!E`b_Q?TZ&m$?dGC6<$!flAX64qhZ$W!j=m*TTH5FR;$LUMcj*FWX9}<0; zzSUmuDF4mw$E8-Y?T!_7JWV_Oc$<c;vIy=dP~z-yCy!`<=8OKc7yj*y+*oiF;#c zO@2uyTS2y8qO|;q*qq=$O`X-(G=qL~8*UB~waye-cj|lS4uPzxmmhsqV0;y$Xtn>u zN535V`)6$9gDrT@2WK#A8>a*xZT|Rc0t4?Jfxj`EPDLNN^PcN{)SfFvQw~`yzHel6 zuc-Nu?x8U8gf!nj+8fPUb@k(YMxh0t zT3>`8sJm)q8|QZB*~}FNGv1tEuq3T=&t8v)Q=*S9XFcNG_-&@I?)MeH7Ve(w*STGo zGvKk)m(N?YLkzwwIwmex_q2MG)csz~N3Ahmyq+4_ZT0EcF31^Br&uMZ*ZaKOtai>* zmeWP2wWnPdWm|fFlTRl|Y~j<>`%=RnmIsw>^aFTeM^kB#qorg>Oe^;}@HtvTDwPuzu} zCihiZ4{2m?O%3~>`bGEn_htV=?)h)seEkFa(m&sKU%B%tSi@yMe{AdfjpECB&1pZ@>&`r~lu@l^*CFV=^B_#8OxbZDM+&4xc=Pv@*w3e?g& z683><*IgscxO&0gXUw?1uk8Ne+VeiPylZ-+-hy)?NypCr{bzgU_-(<>mCH2ecvrDk z2-qX{iTb?sIZgkh^@TS#=Di?fLnR^cY{0RBc?^T9lOtH>+n*1QT&>i zWpg&Ve&JXe-C-y5`25N-Iu%L&dav*V)B$Q#b3IP`;YIb+cWn?q1R%8 z+A|-lOg}DC|H8qWr}(S#!QyFeAA<`eZI}1nHUV?k53T&%>QOCrZRt7DMcdQws@>Wg z(0?nwV@vwN8MmT4?#(ORk#XKhP7rS}e-{;ly*?p?_X0?^*i4?&7@GvQ134 zV-xS3XJ7F9GgGYQt<4uax%{umF8Q!Y_5a=~<42Vw)Ex{C*MG9tM47D9T)jXBq|ejU K&t;ucLK6VP6NzsC diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png deleted file mode 100644 index ad0470ec2a4f7d1bc7c02a084c0f231deee5721e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1131 zcmeAS@N?(olHy`uVBq!ia0y~yV8{Vs4mJh`hW@nhvkVL@MxHK?Ar*0N?_{4y4VF3n zG5J`^Ar-e7K`uV)e4n%)@zGOuW1Hw$`M5Q3@)K6o?I%>H-+9zpRNtr))N-gta%qed zXRFVm7;mNWe|7bz>!N<||8AK+zxbQo`759QURnPvbN$|Lng8eSJs+3-HoeN00Skyq zOKaD?D0q+9KdVeRK3TaV2 zmH+hDm#=#_`9%u+J~-vYllkIZmyY~Cb?S!rG6(g4lOlTC{sqnp{nlN($l0v!AXlCB zUe}wN-+K9OJYm(YSsJt8O#g9*?lpUxMDGRl^&NNgUh}tU=l-e3r|y09(LnL_vV_+8 zQ?sYaJ}NX+d%Z28_59T0DU~08NKEuyp3vHRO7Np`0*l$*Cd>KjYaAkl?75{jJY@Y` zcuo6M?5VS-lus>RD)#ti;7*@N3439-4gXj_v$wsTayRSS%;jqW;`HAd&g-9g^_Y>L zaajC@@+p6lZ=VQys25hJ|K3h4{+OkT9CKqR_cq6yhPU@c?7VX+_aghzeobxN-AZ5H z3r~^n;`{Z(#nQ|2+@-f0_)c3Kj@X)zy*W2qM|b6w%D9`h*}Fq3mA4eydH!kHWRd;) zsQeS_r#*{ojly(qu%FUA^>&)yH-+da%$xW95qlYa|L&>Dr+iN(KCDoW-u!jDeXr^q zt<_>VA$8g|x7%z?GJZ2X_UHRJln%^U%w*0Zm7x-ax+WS=SsjAoi8!ley`Z4pl$?JH7`jGv>H+QGa z&z%}PwcNY9B>BaMB)^Tnnrc_*bmuC2-}erWta5nKwBPzrdBnL@tkuCcEpPAh*y&N} zRvEf8+|uYv;jE=ICpzv6@C)+W{xEm$e<7Lq^6YzWHO0=zjb2(Kxovk+&Q$TTnV;fz z-sF+reAegNtq+Aa%ul@ndw+(%x9sAW3?l{{r0&lZPx5bc$pQG_cT)Q z|Ig`Ls$1*xe@UO%E%H_{Vr#m%D0HtCr$VaL-w^g_3F z&*x;0*v*=KX#T%<+jAaCC~tTv_QsTZ@6b@>cxJ@@-zh;TaRRh8xU3WOe_?>#2D+pRj(O*)2WQbz^?Xfn)kJ z_$SQ|`_@~#$l2=Q^EM}wjggjS-&!>NU K&t;ucLK6UZY$&t< 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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 FCICKZv)cdw 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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 MPgg&ebxsLQ0QTTc0{{R3 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png deleted file mode 100644 index 9d7b47e61a9952b83e0042adead665b13f20f3af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6227 zcmeAS@N?(olHy`uVBq!ia0y~yVB7%09Bd2>3@7{yv=|s9q&!_5Ln`9l-eupC8(PZt zK;wgk|0)qtQ4_)4P8+5-2x_|>6^qU>oR)Eg<;}%}gEyi@Oqn}3mM8PNEWWX!JGPm1 z@rpnZ?yjy`Gc9B9?5Q?iKd0`S<@evmejFFB_#Se6Uin+o>rwR|*cpZ+_~Pjjqp>+S zZT}fwqw=#hDxW*f95(uX>W)gWL)!FTzwaJrXqb7@X-n}lshP>f%Tvys`=j;w_L=`~ z!S(<2KEFO=IrFm7`ExQ8Z?644gTcTwvZCz79i7kL&h($t4>-JH(YNk1zZdtO6ER{~ zrp|q~b#d>Gdy{@XT~=i>O{-4+^c}O!8-LGXFqoE69K(NN&dy!Gd@h_fUVmO@&dqhd z|1cPA(b3qQ*4KZsch|OO?K9I;^dx85GDw7qO|&)E?|=Aw=dr-zl=x1wN4NL>p2d)` zdQqw5ya_+gzItWke066;uiRCGO$&ejVtBx`DIsTmWy+RmKPp%E9Sv{$X~?immHTMo z%*|op6XkxCZ81&%T@tU)u;xtl)k9DA{VIyEXLAS_YpYH_7aeU}wLI>==gh;#x6AJ{ zGracMGVQ1T8cor^-!`A=oY`z7{%)T<!?jKwrtKX!*L<I~By0EouxIZN|B*QGOE`D+ z3i0{z0%Cz%f7ja-FF&Jq_dEN6NvFF1-J5xSttMyp%4f&37C(HQUiM<2Kf^SixHs#? z-kzA88hAU%*1mY<23^qupH6lEneYC8H-CHRcIRy7N6ALwZ}+7$Omm2PATPK1?&pGO zS?&{qjklkEbEEb>gU*IM8|-z(wl^jRJWsnfVP{%|J=+GEPfh>!Y`y+#>9^*L<J)G) zGe?wED*j)-_4<#c;oH5lqgPpNJGw1|p<C-(JY#g=c3=AmJ5xcbY&O<$-{^{R`p|V| z>Y-fMhNFU9H@d9cp6xjEZ{Y;SFYy`)tFQjKk#79`WXzJ^k&G9V7dGVj>AtbEEM9!3 z?Oi&X1AhzCHd9mkZ|7%fw<Je1{8AO!kQ-^a?Wy5R+uif!8H$CtZY&6UcKqGEn>YT- zFwWQ*aG))ey?&o*@!f)G!Gt;|M(L~mM#t0nxLSD<zBw^UyPxf^)h#Yb>wlNdeL$p@ z$&9l!=k#$S_tdg?b;lTd95oUqe_4IzwefYX?V^m!v_&>}uD;!M|GbT@%xxwE+l39j z-lCh<r^(4YJI<h{FR~$Xb?(Z0vv+2uwZD7Lf1tyOarV?%^3|!&9-Jw=`<}5uv6aaz z`~GQ}**hb??`BBgSlDpZIoJE^(KCAwZ`5U2%*mQFuWqiTX0eOKHr51-g$-XFbG^Sl z0>#+_C&t&4->1pV-WdT3|I&pGUww1Ezn(lZ_i(OjgDyAgn)P*)E5o0)&3wE2zVv}z z;#?8;=gzH+d&Uov_jO{7X1Q8c@AfR;xVqhmZ9#+Xj9K#5rp4`N{O-ONN{HoT%@LeC zyE67!|KV*R2dYH5ZYZt(n(TjCMj}s}>9wy$Lif67$M;RzS)Z2t{Wohw)WQbc?yTMa z1D>5Y(|7m1WWvi9rfoUrPVUhu{<=X|bi)}Z#%Q-vsW1Oi{wWrB8rZYxEDks@EmCxo zeoJz6L#hbZjjDj<j{B{P7hfpj*Xe!{&$zlf_wA{=sAu<$^iS9retvPraOUxpc~dF_ zpUKWV@4h>>EbgyC^w-a4yp6)Swu?5pivQI}xLWby<C)3E+xyD&uDzQ%*XXwU^Q7qi zHUIYCx7_Uh?9|NL>GS6P^x4w7FDGv6-FoBCAI`+BwhwP;75c25V0!6S&Xz@`9$S)3 zudP^6XIDJ&%+|%aAO0pu%*^!^-K6!cobz+?0i~{+;v2ML<j>qO-R%GUtB==~q%d~p zYfSh1+nlzk?mx3fwfHZ{cavtsGOlL&wDC-C(y{#?pMQQkZD;xxLHFE_)2vswh4kez zin*Uz)=;ba`TESeyU*KeUvx|P?UK#1QcX~A#jSVQb#E3X*73a9JtL2C_3dE&{%cb% zcE>8dDox=#x5x6<H}9B$-f6FIOv_raQCIYWzu{b_u>B|Nm5aBZX?s`v&2bCgG4}Lx zF@Fz5F0Ny&tbJxR^X=~WiU(qjKH-iKUH|_~jBb_PbfH|6u(RCrE&i8ouCzZPGyUfN z-;zvce0{bxT>Z8Bd3xDvJOBAlU*FaLeCo{l>wBcbRx{PRJ*xv{D#Zh5jy~bK;cv{} zsVP$`zm_rCcUOIS%*AW{veIkM?mwSZ_v%ZAH}gJ`&HB4c^DDfbJzMxzZpQK%K8(NP zp7j|va!1*^ZHcv5C!X8)s^v`eqOAw6v;Mw3bF%SqcWGW3skJ|P?y5}c->CR`!I_&E z?XUie|1Bvu@uvCa?6mi1co*mHG<{u~XWxBoLGjfY2h#Zswn*%6xccXWZqk|Vh%L*y zUZ>3y&8<ItD=sVgbI{lO*?)s_okM>sUz3bTE<Nk2y`cO=P5R}_XJSo*uSl&uyKS4h zcG}nb+2y^Ft7|pnr|(SpUdreb+7o`@Ra4~bH+?aCKBbqRxnpum<I(YrZx?M<{Tj7z zex>i0V?DCF&YP~tI4f&@?$7mA$LCLvPm!B?bLnrZ@NP4y)vb>{2W%I$7th`J<Jh}w z<{4cQ>l?1FJ?vc~Uo3FZ&C2vqRj^N;-14$md&6UU_D;30KAN#z|M}MH<B}J*-MRIw zy2H%;N5hQs8ab|_oBnr{<(xjT-+_Tg<(PKD`+mV$W{Yy)hHRhv>f2+JTI*Y`wW}9T zU)yQ6Y31$WQtAA#ERkOiGq~5W=S7^{U)`K>wEA9_%HKO@j_z77n2?mThbe6F<i_W7 z7WE3P6Mg&0X7xm;UanVnHk>YxKfmBpdijYvX1CsbyIgnI{)~;{HiP?HO)r)`T%UKr zF2a&`*DRlHmQ5@YQ9a=Y_RYJw`1g$2!fOw&FMBrQ_&M=X!?k?Jl#`G6oy<7;z2Qu; zk^QYN7lebi2d>_|N$-d9+p3B7<;!2({}yqTr}(=>k_xB61W=&3e($?`{LQKTn+<QR zJoS0s-q6<N*H-n~*bBc^IGgPj{Uhe>x5s@aUR`_QzDU>k#@638&0W`y{%kYtzaxKD z^Oty{3Y$+*Pxt|^()qFLe(%s;bXM`I;kV6k%eSa*4cHm=&RtJT^;YiDY?tic|Gl<2 znPjl1){0%Ni(_4Gc1AWsWVh<YU25+|H_u%zeJ%2$+X1(v11U#-u&&X&wUzJnh78@0 z+PhP)=pN2;T5&t+^Q%X<Cuu2cb*OstICha}e^f+_b+C=;=f;fVTd%BGd~H)`R@|y* z+g`D}deyhb{n;kNBYz%FbCDLj_W0`K@3FexHy-I*GdOpC<i64M>+Sz3XSf%YzMU)b zd4K8--TbpMH~#v#iLNaOcU*nDa{8$l%P8%7S)rZoTb42lOE*4AemkYoXUo>4?nTpl z-`uIyXK?O5DY`+_R7v_v*sVQR|Ah9+@xQFotDU~Sa{U?Z#s90#%dYBM-JWC4dSls) z8pddstCEp(i<0kOn!PP!@t)N#(r^22I~eJ2?MZr(%EXY}!nAGCvsLrv-rW6L@~i6> z%VWD!_U(E$RezpT>)n07|1q=)aoxx=U9|F|<J)G7OHpewJIr3~DtYF*G2(M<^yg_a z9@Hpo;A>^tW_i{0$CnK5>CdYso_&5_^_J8s{o{|$=<+SP^~(L0yYPeWPK>uFN%da+ zwT}ImjNgpDYjX7wTXHx2+Osb^Wb2kkW_Q2y8f*wSux+W{meP)Ae~rcaHQ%cV=3bv< zwPll-rT*!+hjc5w&Q!K=>ud-(z!rKt?P}V-t*T+AjCn8WrY-I=J6?J+<9X&@?OU10 zUpt;{_Wdf(l+fAAWVU8j`zp&#-_!c~%9h=<yJ*F-eP>|7mezAyc|+$eis7ESy7vFo zi+y#GSGN5KyvjIdZ~QKXFCiKUk5_&BowZ?w@Y4T=pTlR~cA33@m*1Ah$*<<Di+0IA zwqmaFKhw`AGt@owgOiVKu#-=I$IQ7V`Qmw|F!8U&i{E~W`^^>BxPtqiyYc)}bL4W3 z@7&5SnBiNJzA(=FZr+-otF`i`-U?b>cIL(Hc@sW|x!;M|^83GML!(PQS48ZS?9I39 zPugfiDIb2iXzsn3YVK?M>Pu&@`?sck-Cox961l>6_ji{`uhZ)|ePiSHHLp~dl?@V1 z4DU0ADNns@blh$C*=^}telA>Sb@|nM_uGH>m9_rAe8zZD@2y{DJCw7pcHEMYFqk!? zmT`4-PU(gxuNTebH=Sx4_H&1LubFu0>lLeBHvL;U_hqRf)8fvXVjE_OuVQO^b!Ya; z{CgWSV?$-<KKhol|H`D6g>JVQ{_Cd&A9xj(xHf-RBF}{^JKl?VzSn9ZWi4J_d-UgO zdBN&;-&qVc%&24hUA5)fr{25I=e1{?mFGWSlYe$~%C#)cgLQY`vnM1N?qw>A+j8!} zE0s4_ZQQp^p16LVrDe~tXHQG-zGqF)09A2|XYW7x=2GoGyG}Ey>X>K8v*T<phZQfZ zzxLOTp-pJAZo<{Xh~KRLJkMmV%Z|8MR%@?Sd~DVCgC%?Q*ZgNXCOTO=VQc^W+s4yh z+8tMMiGKa{cgg?TDlWIDe`vnAKGxQ3d0hP6d25f&__g`{`t7UT;<vx6`v=NrA{%Zg zy<BQ|Ft>40Zx7#zJ37VlGkV3h_CL6Iu`G8z>*?tooPA$DZT!~1EpN^G*{`mxJtyui zZ5U@=y!y;%WBZ7|)2rTz8ZK59IWVdFBX>kE%RDLJ7e;4}@9@|X_WSXox0MmqvezD8 z*GNuXvhmaY?EHhbv_Brc$aUK^nW;GMVj1&FrNG6r_pSIIZ9OeU--^}x*1K<4Cw%&N z)&4^8*3v7DtK;ntoJjh^6lR^(6S`;7+gX8&*L-)e=FYxxJL&89)i<t2ZMIG=dvAB# zV3BTJUC<WZ*53<usq(+tckb$^{qi!ok00+_b!?vW(J6V1%&!A_!VjD~DRZm#jHB!A z72oqNx<yZx`grBUnrE9{eY<r+c~gco`*qDTpZC~?zh9cS^X={0+SrTJ*lcv$^#5%x z2zyp`ar0A=15Mo@xo#{^Zc!3^n-ra%`nTF=T|w#9ZGr21w<Z7e5I>jUt898bZ2S8( zTm7)#uGjQ_GuQ9?S)MPn_Qltek~43&O%ylCG>l_h9UblLd%}J8^D37upZBCa>^W0w zEWK-eYQWFU>}hdwxpsNCRs~$sXEX0rlnz;RmigM+D|Rcs2YxPUZ+pb*<J=Q|fK9H^ zRr+j<+2?ID{vPZzJ!P#}{PEpusgNz}PRxp5?beccciHd6XD4Uw-F4o+<HdQ7HF>-3 z6y0j%8JIgiaz&WN$lR$tvo7Q4YBBTcjZ-i0`jPM5%aN7$X4T#6<|{JZKb~~)oc`}` zuh`{-@4l<?-Ew=??eh8M0vC4)&KLUpyT-dW;QpMQVLX;qkP7ou*lvNvy)o|u{@%3t zE<N*b@{!7oLH5#bw>^5ixc7vc<+7#U64tCcD7;wo`@+1VWlzt`Px^W8mHLc@QV*}a zJrtc@_HLh9!^)12+!24T#i}dBCkPh*+a0^uROw39TDz6pf9EEj33S>bc(d*z*B5W2 z_%1W{tvioRkzx$H{%qz&H%<P&YY9e|I<mLc^k}E}-Te|`9K3jMewv&ZbBPy|Pe@Pv z0k5-}S3iCz4P19S;i^x->s|L`H)qyJxQfnVKQ=>ozUW0O?ehL@a;u|`z1}E4w=(D1 z!i&?m?<f>+<g`l{y>M&d;$14~wXZjnu~s@h>vx*WX25SalWAL2tVepSp=_vbNL^LW z&JUGxi$u@tO8vgp^nJ#!Ri01gUd*fRtC1E<$T9rMv@Ih`Z&lvQ*j2x8ehJ-ojrZcW zwl!OmHV5sWaz*#j?zFPE`_vj{a<i_va`Y$n%;;k?J||rknLSNuk5*c_Kzgm2Y5w;U zXYSp-&SGG`uwiZ3SvAx2tGmB?U)*JxUA^Y~kvDl4|JmHqWZoKCkk4(s!LOC+S}3pP z)r;j7JsYc!FLBAfxjyhZ_u^8+b53WAuO2&P-p5(u$>igzkq{je+ZAe}CtsWucH;S6 z<>LKkUK-904Bz^z-m>`pnY_E#nJ;KBY{)%(>W_bJcgf5dfr{PRy#LD@&+j<9UUT-A zs`*=!{|GOZ6<Vb$_hNs*0k%N#A5nk)tjT!2uIyD^-{Y>b)f>7})-GLr$aMAB;?n3% zeK{*D*Uc2kXz1i%&5<bWUMpqm*Hso4@peg|?px>nh*#;0MA!aZY?{7D=hnHOn_uaz z*}0EDwXa80B&A^`H*1be=$S0`NzYT>$>lEZ&DwE8`&RA&E30Ke(OFmZWJA~Ros|(? zdwb(K=heQA>6O<aMAxl%(LQjkn6VqwnO&*$<#I-G+SQW4<v!Oro-@zvca3KGb2h_$ z*_ouc?BZ31PiJ22dUIj1TSFWtYmV&--Ocx=>~x#)oN1b?M#AZwry);n>E5>y-w*@p z@Le_idH-VHrkm<_^jM|^Y9yRq)@Ql${g(v+I~r2U{vGW*8vgp&s=fa|oZ-6*?J~Yt zrMu7U$J%5+wW0vE15O;QITQaX+^X8tUA{lOVW~LR4W(a^&sxriIz`)Th-+oq=8-k; z_1&4X-TC{f7-y^wI3VVDw)m=_T>Z?Odw<I^t?^#i@HKMn^Ivt(JZJvheP8y#E-|i% z_^T^VtUoIw^lT@Co`%SVeJdY_WHCR8PTBUlE}mhIzQ_i<pKe>2b-%x<^Jge&VKU1% zJzpC3OmZgkp>O^S&EPKi%*y0vRS(M)4-_~t&TgOe{8!4BWWTa^b?OW}3L+aKSKV&C ze@15V&DFnS84Owi4x~L5pZVB$I)Cm*hR58jIk&Rrz0RHay1i1b;Up((PSmTihtG|^ zpNU!WdndEOvVa3>Yq_p(H|{_EM*7wx#%0PP8$5qiZb|lAC&MLvpae7`@jUh3Y)d7X zVkQIWg$=!r%V(ZXe>eN)jlVgJGZqCLa0|U{Z>w7T@`4z@LG{80-qp``#!cLrHRCzc zjQOBu({iQxvv!uIEr0i%<3LX<(>C3!V*jsZ9G??$p7nw_C?VgRnV!bSvz?m}G?;RB z<=gr9rtB<B`~40&+7cyO{Apsw@i{Y|GriaxaKP(q@!Xk}A!+jqb_*={@5H#ea-G$) zCkNi8GduJj{iU05RqX$|Gl?^0cg^Q!uoZv3z9ICXm2UC$GkSN=GdHkK6#w{q?cRO* zTdkfw`8f0MAsg`nNvFAg1}`=>)Y&{gEpFD%APZ5+1j{F^e_v$mUZua->e-VeXM7J; z=`}nR$~_w#`(paZQ{Vhc&zxL$_dCx4p%dJHLKodliWa+nT4wsq-M`})Ba|u^-q-&8 za;s^A;?-~d>rcrD<%u)t?B8=EUL#8T?;^9JD{s#S8@Z>Iz1e5aFfAhPNB@aGNxqRU z_Gj5x+J3%pM(*x+mIGE3SQC<;9nL=GTz}Q$qx;PCbUoQ?#f)q6FP1I)arKqgMQ79V zUyq$JH5PxjkDcM#LLH9XseW<6dcU3qUyS-^UKVf7@H%D-v&{b8+w#tY-qrgP^=yX0 zdDa8dQYP*wcy{n&f9qNM&ayKnpB{ee#IQ}Z`)J?H=g0nZMVu|(t6aQy!#wVWM3>q8 zt1q6<4-22Bx%zf%>6w#v4;iX6^sMUgc$R7S_iJM4j;woc3uo3kN82zwK9#gd@m9wQ zDa~aIpY8NJBO~%`C&P>-8PUG6mY+YJv0UAC-Xg2+t?k7!hMOxvW21(V8QV_Bn16ni zVZHBvS+K2?NABMc<#pw0eO#^F4Uwk~ZFYXvHM2eS-YiSw;=C<^p`u&0LcXS(HiWo8 z<TrkQ+Q#md)#kao`#BkmMc2-FEBDrCV|3cPi96$;#a(oZynf=1>{`?KV$f(h!!QE| a?El%9>w0pRd|$j7<TX!MKbLh*2~7Z?hON>7 diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 000000000..2f67f54d7 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + + <style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_dark</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_oled</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_light</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> +</resources> diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 229435b03..c2158f1d7 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 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <color name="ic_launcher_background">#1a1a1a</color> + <color name="splash_background">#0E0F12</color> + <color name="splash_dark">#0E0F12</color> + <color name="splash_oled">#000000</color> + <color name="splash_light">#F7F7F8</color> </resources> diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index f86351da3..01337e8d1 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 @@ <resources> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> <!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation --> <item name="android:defaultFocusHighlightEnabled">false</item> <item name="android:colorControlHighlight">@android:color/transparent</item> @@ -13,9 +14,31 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> - <item name="android:windowBackground">@android:color/black</item> + <item name="android:windowBackground">@color/splash_background</item> <!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation --> <item name="android:defaultFocusHighlightEnabled">false</item> <item name="android:colorControlHighlight">@android:color/transparent</item> </style> + + <!-- Splash theme variants for setSplashScreenTheme --> + <style name="SplashTheme.Dark" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_dark</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Oled" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_oled</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Light" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_light</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.System" parent="@android:style/Theme.Black.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> </resources> 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 000000000..1aa7df0a9 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + + <style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_dark</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_oled</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_light</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> +</resources> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ab9832824..a4535a0b8 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,4 +1,8 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <color name="ic_launcher_background">#ffffff</color> + <color name="splash_background">#F7F7F8</color> + <color name="splash_dark">#0E0F12</color> + <color name="splash_oled">#000000</color> + <color name="splash_light">#F7F7F8</color> </resources> \ 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 dfada3aea..c927cb197 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -2,6 +2,7 @@ <resources> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> <!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation --> <item name="android:defaultFocusHighlightEnabled">false</item> <item name="android:colorControlHighlight">@android:color/transparent</item> @@ -13,9 +14,31 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> - <item name="android:windowBackground">@android:color/black</item> + <item name="android:windowBackground">@color/splash_background</item> <!-- Disable Android's default green focus highlight that appears during d-pad/keyboard navigation --> <item name="android:defaultFocusHighlightEnabled">false</item> <item name="android:colorControlHighlight">@android:color/transparent</item> </style> + + <!-- Splash theme variants for setSplashScreenTheme --> + <style name="SplashTheme.Dark" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_dark</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Oled" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_oled</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.Light" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_light</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> + <style name="SplashTheme.System" parent="@android:style/Theme.Light.NoTitleBar"> + <item name="android:windowBackground">@color/splash_background</item> + <item name="android:defaultFocusHighlightEnabled">false</item> + <item name="android:colorControlHighlight">@android:color/transparent</item> + </style> </resources> diff --git a/assets/plezy_adaptive_foreground.svg b/assets/plezy_adaptive_foreground.svg new file mode 100644 index 000000000..257f10635 --- /dev/null +++ b/assets/plezy_adaptive_foreground.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-276.65 -223.88 911.63 911.63"><defs><linearGradient id="linear-gradient" x1="22.67" y1="425.72" x2="201.83" y2="115.4" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#linear-gradient)" d="M335.65,192.66L68.01,38.14C37.78,20.69,0,42.5,0,77.41v309.04c0,34.91,37.78,56.72,68.01,39.27l267.64-154.52c30.23-17.45,30.23-61.08,0-78.54ZM255.53,276.8c-19.1,17.66-38.75,26.49-58.4,26.49s-39.29-8.83-58.39-26.49c-14.39-13.3-28.55-20.05-42.11-20.05s-27.72,6.75-42.1,20.05c-4.87,4.5-12.46,4.2-16.96-.67-4.5-4.86-4.2-12.45.67-16.95,38.2-35.33,78.59-35.33,116.79,0,14.38,13.3,28.55,20.04,42.1,20.04s27.72-6.74,42.11-20.04c4.86-4.5,12.46-4.21,16.95.66,4.5,4.87,4.21,12.46-.66,16.96ZM255.53,204.04c-19.1,17.67-38.75,26.5-58.4,26.5s-39.29-8.83-58.39-26.5c-14.39-13.3-28.55-20.04-42.11-20.04s-27.72,6.74-42.1,20.04c-4.87,4.5-12.46,4.21-16.96-.66s-4.2-12.46.67-16.96c38.2-35.32,78.59-35.32,116.79,0,14.38,13.3,28.55,20.05,42.1,20.05s27.72-6.75,42.11-20.05c4.86-4.5,12.46-4.2,16.95.67,4.5,4.86,4.21,12.45-.66,16.95Z"/></svg> 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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 AD*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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 diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index f4fb11540..fdfbba20c 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 dd0696050..cbc18e9ef 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 3440384f2..7e80021f4 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 585b23e0b..9a0f1315f 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 9c7879687..b1da29262 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 fa95896b0..1fcca5ef8 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 6269ccd20..e08757ae1 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 8a362f190..e89a84e9c 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 2da53df62..285c991e7 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 6d5f756c1..2b36a0ddd 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 53dab1acd..79dac6a84 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 20ea62c69..96039f0ce 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 c34fb3f1e..891b5017e 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 da12c779b..fc2ca4055 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 b7d2868bc..735b8ab46 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 37a6fff01..013cfa06f 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 2f52a17e5..9a0761e9b 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 3453e72a9..5b08f62c5 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 84820f9a2..7f1cebd46 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<SetupScreen> { + String _statusMessage = ''; + @override void initState() { super.initState(); _loadSavedCredentials(); } + void _setStatus(String message) { + if (mounted) setState(() => _statusMessage = message); + } + Future<void> _loadSavedCredentials() async { + _setStatus(t.common.checkingNetwork); + final storage = await StorageService.getInstance(); final registry = ServerRegistry(storage); @@ -386,6 +396,8 @@ class _SetupScreenState extends State<SetupScreen> { 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<SetupScreen> { 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<SetupScreen> { // No network — skip connection attempts and go straight to offline mode if (!hasNetwork) { + _setStatus(t.common.startingOfflineMode); await context.read<DownloadProvider>().ensureInitialized(); if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)), + 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<SetupScreen> { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)), + fadeRoute(MainScreen(client: result.firstClient!)), ); } else { + _setStatus(t.common.startingOfflineMode); await context.read<DownloadProvider>().ensureInitialized(); if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)), + fadeRoute(const MainScreen(isOfflineMode: true)), ); } } catch (e, stackTrace) { appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace); if (mounted) { + _setStatus(t.common.startingOfflineMode); await context.read<DownloadProvider>().ensureInitialized(); if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)), + fadeRoute(const MainScreen(isOfflineMode: true)), ); } } @@ -468,12 +487,33 @@ class _SetupScreenState extends State<SetupScreen> { @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 2962db091..98743ea8f 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<void> _initializeSettings() async { _settingsService = await settings.SettingsService.getInstance(); _themeMode = _settingsService.getThemeMode(); + _updateSplashTheme(_themeMode); notifyListeners(); } @@ -63,14 +66,28 @@ class ThemeProvider extends ChangeNotifier { } } + static const _themeChannel = MethodChannel('app.plezy/theme'); + Future<void> setThemeMode(settings.ThemeMode mode) async { if (_themeMode != mode) { _themeMode = mode; await _settingsService.setThemeMode(mode); + _updateSplashTheme(mode); notifyListeners(); } } + void _updateSplashTheme(settings.ThemeMode mode) { + if (!Platform.isAndroid) return; + final name = switch (mode) { + settings.ThemeMode.dark => 'dark', + settings.ThemeMode.oled => 'oled', + settings.ThemeMode.light => 'light', + settings.ThemeMode.system => 'system', + }; + _themeChannel.invokeMethod('setSplashTheme', {'mode': name}); + } + String get themeModeDisplayName { switch (_themeMode) { case settings.ThemeMode.light: diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 5dc25f79c..508b0ad1a 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<AuthScreen> { 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 000000000..b729af77f --- /dev/null +++ b/lib/utils/navigation_transitions.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +Route<T> fadeRoute<T>(Widget page) { + return PageRouteBuilder<T>( + 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 99582c4e8..d014e3429 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 0acf78639..8f6e0d138 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 82a06537f..a32701251 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<int, String> _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 6675b08d8..5bd490ac9 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<String>() + options.add("start=$startSeconds") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) + val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + + // Request audio focus + mpvCore?.requestAudioFocus() + + // Emit backend-switched event so Flutter can show notification + onEvent("backend-switched", null) + + Log.i(TAG, "Successfully switched to MPV fallback") } - - // Setup property observers - mpvCore?.observeProperty("time-pos", "double") - mpvCore?.observeProperty("duration", "double") - mpvCore?.observeProperty("pause", "flag") - mpvCore?.observeProperty("paused-for-cache", "flag") - mpvCore?.observeProperty("demuxer-cache-time", "double") - mpvCore?.observeProperty("eof-reached", "flag") - mpvCore?.observeProperty("track-list", "string") - mpvCore?.observeProperty("aid", "string") - mpvCore?.observeProperty("sid", "string") - mpvCore?.observeProperty("volume", "double") - mpvCore?.observeProperty("speed", "double") - - // Show the MPV surface - mpvCore?.setVisible(true) - - // Load media at the same position - val startSeconds = positionMs / 1000.0 - val options = mutableListOf<String>() - options.add("start=$startSeconds") - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") - } - val optionsStr = options.joinToString(",") - // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) - val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri - mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) - - // Request audio focus - mpvCore?.requestAudioFocus() - - // Emit backend-switched event so Flutter can show notification - onEvent("backend-switched", null) - - Log.i(TAG, "Successfully switched to MPV fallback") } catch (e: Exception) { 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 86f6a8b6c..158af5513 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 a92e7d3b5..37eacdac3 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 25e45c204..cf9347a17 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<String, dynamic> json) { + /// Extract an image URL by type from the Image array in raw JSON + static String? _extractImageFromJson(Map<String, dynamic> json, String imageType) { if (!json.containsKey('Image')) return null; final images = json['Image'] as List?; if (images == null) return null; for (var image in images) { - if (image is Map && image['type'] == 'clearLogo') { + if (image is Map && image['type'] == imageType) { return image['url'] as String?; } } return null; } - /// Create from JSON with clearLogo extracted from Image array + /// Create from JSON with Image array fields extracted factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) { - // Extract clearLogo before parsing - final clearLogoUrl = _extractClearLogoFromJson(json); - // Add it to the json so it gets parsed + final clearLogoUrl = _extractImageFromJson(json, 'clearLogo'); if (clearLogoUrl != null) { json['clearLogo'] = clearLogoUrl; } + final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare'); + if (backgroundSquareUrl != null) { + json['backgroundSquare'] = backgroundSquareUrl; + } return PlexMetadata.fromJson(json); } + /// Returns the best hero art path based on the container's aspect ratio. + /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. + String? heroArt({required double containerAspectRatio}) { + // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 + if (containerAspectRatio < 1.39 && backgroundSquare != null) { + return backgroundSquare; + } + return art; + } + // Helper to get the display title (show name for episodes/seasons, title otherwise) String get displayTitle { final itemType = type.toLowerCase(); diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index eb2a598e2..d934f63b4 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -57,6 +57,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata( extraType: (json['extraType'] as num?)?.toInt(), primaryExtraKey: json['primaryExtraKey'] as String?, clearLogo: json['clearLogo'] as String?, + backgroundSquare: json['backgroundSquare'] as String?, ); Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{ @@ -110,4 +111,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn 'extraType': instance.extraType, 'primaryExtraKey': instance.primaryExtraKey, 'clearLogo': instance.clearLogo, + 'backgroundSquare': instance.backgroundSquare, }; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 7dbfffd6d..629b1148b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1264,7 +1264,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> } }, itemBuilder: (context, index) { - return _buildHeroItem(_onDeck[index]); + return _buildHeroItem(_onDeck[index], heroHeight); }, ), // Bottom gradient that extends past hero bounds to ensure seamless blend @@ -1380,7 +1380,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> ); } - Widget _buildHeroItem(PlexMetadata heroItem) { + Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) { final isEpisode = heroItem.isEpisode; final showName = heroItem.grandparentTitle ?? heroItem.title; final screenWidth = MediaQuery.of(context).size.width; @@ -1406,7 +1406,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> clipBehavior: Clip.none, children: [ // Background Image with fade/zoom animation and parallax - if (heroItem.art != null || heroItem.grandparentArt != null) + if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null) ClipRect( child: AnimatedBuilder( animation: _scrollController, @@ -1429,9 +1429,10 @@ class _DiscoverScreenState extends State<DiscoverScreen> final client = _getClientForItem(heroItem); final mediaQuery = MediaQuery.of(context); final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final containerAspect = screenWidth / heroHeight; final imageUrl = PlexImageHelper.getOptimizedImageUrl( client: client, - thumbPath: heroItem.art ?? heroItem.grandparentArt, + thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt, maxWidth: mediaQuery.size.width, maxHeight: mediaQuery.size.height * 0.7, devicePixelRatio: dpr, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0f357a37c..48033a142 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1853,14 +1853,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw SizedBox( height: headerHeight, width: double.infinity, - child: metadata.art != null + child: (metadata.art != null || metadata.backgroundSquare != null) ? Builder( builder: (context) { + final containerAspect = size.width / headerHeight; + final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); + // Check for offline local file first if (widget.isOffline && widget.metadata.serverId != null) { final localPath = context.read<DownloadProvider>().getArtworkLocalPath( widget.metadata.serverId!, - metadata.art, + heroArtPath, ); if (localPath != null && File(localPath).existsSync()) { return Image.file( @@ -1879,7 +1882,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); final imageUrl = PlexImageHelper.getOptimizedImageUrl( client: client, - thumbPath: metadata.art, + thumbPath: heroArtPath, maxWidth: mediaQuery.size.width, maxHeight: mediaQuery.size.height * 0.6, devicePixelRatio: dpr, diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index e01191706..cff9e81b1 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 a32701251..a7d10d36a 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 f09126bb5..ee8ffa930 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 7f1cebd46..dec725305 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 233c63fd4..09a692688 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 d7285ca16..000000000 --- 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 e367c8529..000000000 --- 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<String, dynamic> json) => _$TrustedDeviceFromJson(json); - - Map<String, dynamic> toJson() => _$TrustedDeviceToJson(this); - - TrustedDevice copyWith({ - String? peerId, - String? deviceName, - String? platform, - DateTime? firstConnected, - DateTime? lastConnected, - bool? isApproved, - }) { - return TrustedDevice( - peerId: peerId ?? this.peerId, - deviceName: deviceName ?? this.deviceName, - platform: platform ?? this.platform, - firstConnected: firstConnected ?? this.firstConnected, - lastConnected: lastConnected ?? this.lastConnected, - isApproved: isApproved ?? this.isApproved, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is TrustedDevice && other.peerId == peerId; - } - - @override - int get hashCode => peerId.hashCode; -} 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 38a195dc2..000000000 --- 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<String, dynamic> json) => TrustedDevice( - peerId: json['peerId'] as String, - deviceName: json['deviceName'] as String, - platform: json['platform'] as String, - firstConnected: json['firstConnected'] == null ? null : DateTime.parse(json['firstConnected'] as String), - lastConnected: json['lastConnected'] == null ? null : DateTime.parse(json['lastConnected'] as String), - isApproved: json['isApproved'] as bool? ?? false, -); - -Map<String, dynamic> _$TrustedDeviceToJson(TrustedDevice instance) => <String, dynamic>{ - 'peerId': instance.peerId, - 'deviceName': instance.deviceName, - 'platform': instance.platform, - 'firstConnected': instance.firstConnected.toIso8601String(), - 'lastConnected': instance.lastConnected.toIso8601String(), - 'isApproved': instance.isApproved, -}; 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 8bd629f80..4c512d182 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 3e9f51075..f7c2f9a0b 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<bool> Function(RemoteDevice device); class CompanionRemoteProvider with ChangeNotifier { RemoteSession? _session; CompanionRemotePeerService? _peerService; String _deviceName = 'Unknown Device'; String _platform = 'unknown'; - final List<TrustedDevice> _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<RemoteSessionStatus>? _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<TrustedDevice> get trustedDevices => List.unmodifiable(_trustedDevices); bool get isPlayerActive => _isPlayerActive; CompanionRemoteProvider() { _initializeDeviceInfo(); - _loadTrustedDevices(); } Future<void> _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<void> _loadTrustedDevices() async { - try { - final storage = await StorageService.getInstance(); - final json = storage.prefs.getString(_storageKey); - if (json != null) { - final List<dynamic> list = jsonDecode(json); - _trustedDevices.clear(); - _trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>))); - appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices'); - } - } catch (e) { - appLogger.e('CompanionRemote: Failed to load trusted devices', error: e); - } - } - - Future<void> _saveTrustedDevices() async { - try { - final storage = await StorageService.getInstance(); - final json = jsonEncode(_trustedDevices.map((e) => e.toJson()).toList()); - await storage.prefs.setString(_storageKey, json); - appLogger.d('CompanionRemote: Saved ${_trustedDevices.length} trusted devices'); - } catch (e) { - appLogger.e('CompanionRemote: Failed to save trusted devices', error: e); - } - } - - bool isDeviceTrusted(String peerId) { - return _trustedDevices.any((d) => d.peerId == peerId && d.isApproved); - } - - Future<void> addTrustedDevice(RemoteDevice device, {bool requireApproval = true}) async { - final existing = _trustedDevices.where((d) => d.peerId == device.id).firstOrNull; - - if (existing != null) { - final updated = existing.copyWith( - deviceName: device.name, - platform: device.platform, - lastConnected: DateTime.now(), - isApproved: !requireApproval || existing.isApproved, - ); - _trustedDevices.remove(existing); - _trustedDevices.add(updated); - } else { - bool approved = !requireApproval; - - if (requireApproval && onDeviceApprovalRequired != null) { - approved = await onDeviceApprovalRequired!(device); - } - - _trustedDevices.add( - TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved), - ); - } - - await _saveTrustedDevices(); - - if (isRemote) { - final storage = await StorageService.getInstance(); - await storage.prefs.setString(_lastDeviceKey, device.id); - } - - notifyListeners(); - } - - Future<void> removeTrustedDevice(String peerId) async { - _trustedDevices.removeWhere((d) => d.peerId == peerId); - await _saveTrustedDevices(); - notifyListeners(); - } - - Future<void> approveTrustedDevice(String peerId) async { - final device = _trustedDevices.where((d) => d.peerId == peerId).firstOrNull; - if (device != null) { - final updated = device.copyWith(isApproved: true); - _trustedDevices.remove(device); - _trustedDevices.add(updated); - await _saveTrustedDevices(); - notifyListeners(); - } - } - - Future<String?> getLastConnectedDevicePeerId() async { - final storage = await StorageService.getInstance(); - return storage.prefs.getString(_lastDeviceKey); - } - @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 677b70b7a..5c51dae4c 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 f06e69bb5..6fa0dd495 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 5c7ae1e4b..ac92ad8c9 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 b34fbb26a..4b0974339 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 276975e90..a4887a1b6 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 b2629ff87..bb844658b 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 7c4899f40..bba466923 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 fb6f05590..a394da603 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 bd31cb77b..d04d80d7b 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 276cd70a0..000000000 --- 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 cc302806c..000000000 --- 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<void> 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 215b032a2..000000000 --- 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 c301c8665..770c66165 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<void> 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<bool>('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 d85fedb4c..be11cb260 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 <mainmoniker@googlemail.com> 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 9afd4282c..4b79c75d9 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 ef7150995..574d31515 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<PlexConnection> findBestWorkingConnection({String? preferredUri}) async* { + Stream<PlexConnection> 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 7aac83c8c..e9ebd435f 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 = <String, String>{'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 = <ConnectionTestResult>[]; 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 <mainmoniker@googlemail.com> 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 508b0ad1a..6092ff555 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -97,6 +97,7 @@ class _AuthScreenState extends State<AuthScreen> { multiServerProvider: context.read<MultiServerProvider>(), librariesProvider: context.read<LibrariesProvider>(), syncService: context.read<OfflineWatchSyncService>(), + clientIdentifier: _authService.clientIdentifier, ); if (!result.hasConnections) { 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 3ee3dc4da..182e1d915 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 d014e3429..bd16bc25c 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 8f6e0d138..a511a848b 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 b97475901..eec524f85 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 182e1d915..ec1f22500 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 7ad6cfe96..357379178 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 3f3eaa1fd..6dff4316f 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<LogsScreen> { List<LogEntry> _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<LogsScreen> { } } - IconData _getLevelIcon(Level level) { - switch (level) { - case Level.error: - case Level.fatal: - return Symbols.error_rounded; - case Level.warning: - return Symbols.warning_rounded; - case Level.info: - return Symbols.info_rounded; - case Level.debug: - case Level.trace: - return Symbols.bug_report_rounded; - default: - return Symbols.circle_rounded; - } - } - - @override - Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Text(t.screens.logs), - actions: [ - IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), - onPressed: _loadLogs, - tooltip: t.common.refresh, - ), - IconButton( - icon: const AppIcon(Symbols.upload_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _uploadLogs : null, - tooltip: t.logs.uploadLogs, - ), - IconButton( - icon: const AppIcon(Symbols.content_copy_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - tooltip: t.logs.copyLogs, - ), - IconButton( - icon: const AppIcon(Symbols.delete_outline_rounded, fill: 1), - onPressed: _logs.isNotEmpty ? _clearLogs : null, - tooltip: t.logs.clearLogs, - ), - ], - slivers: [ - if (_logs.isEmpty) - SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) - else - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final log = _logs[index]; - return _LogEntryCard( - log: log, - formatTime: _formatTime, - levelColor: _getLevelColor(log.level), - levelIcon: _getLevelIcon(log.level), - ); - }, childCount: _logs.length), - ), - ), - ], + void _scroll(double delta) { + final pos = _scrollController.position; + _scrollController.animateTo( + (pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent), + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, ); } -} -class _LogEntryCard extends StatefulWidget { - final LogEntry log; - final String Function(DateTime) formatTime; - final Color levelColor; - final IconData levelIcon; + List<TextSpan> _buildLogSpans() { + final spans = <TextSpan>[]; + for (var i = 0; i < _logs.length; i++) { + if (i > 0) spans.add(const TextSpan(text: '\n')); + final log = _logs[i]; + final color = _getLevelColor(log.level); + spans.add(TextSpan( + text: '[${_formatTime(log.timestamp)}] ', + style: TextStyle(color: color.withValues(alpha: 0.6)), + )); + spans.add(TextSpan( + text: '[${log.level.name.toUpperCase()}] ', + style: TextStyle(color: color, fontWeight: FontWeight.bold), + )); + spans.add(TextSpan(text: log.message)); + if (log.error != null) { + spans.add(TextSpan( + text: '\n Error: ${log.error}', + style: TextStyle(color: color), + )); + } + if (log.stackTrace != null) { + spans.add(TextSpan( + text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}', + style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)), + )); + } + } + return spans; + } - 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 000000000..89f9c5516 --- /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<FocusableAction> actions; + + /// Called when the user presses down from any action button. + final VoidCallback? onNavigateDown; + + /// Called when the user presses up from any action button. + final VoidCallback? onNavigateUp; + + /// Called when the user presses left from the leftmost button. + final VoidCallback? onNavigateLeft; + + /// Called when the user presses right from the rightmost button. + final VoidCallback? onNavigateRight; + + /// Called when the user presses the back key while an action is focused. + final VoidCallback? onBack; + + const FocusableActionBar({ + super.key, + required this.actions, + this.onNavigateDown, + this.onNavigateUp, + this.onNavigateLeft, + this.onNavigateRight, + this.onBack, + }); + + @override + State<FocusableActionBar> createState() => FocusableActionBarState(); +} + +class FocusableActionBarState extends State<FocusableActionBar> { + late List<FocusNode> _focusNodes; + late List<bool> _focusStates; + + /// Access a focus node by index (e.g. for external `requestFocus()` calls). + FocusNode getFocusNode(int index) => _focusNodes[index]; + + @override + void initState() { + super.initState(); + _initNodes(); + } + + @override + void didUpdateWidget(FocusableActionBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actions.length != widget.actions.length) { + _disposeNodes(); + _initNodes(); + } + } + + void _initNodes() { + _focusNodes = List.generate(widget.actions.length, (i) => FocusNode(debugLabel: 'ActionBar[$i]')); + _focusStates = List.filled(widget.actions.length, false); + for (var i = 0; i < _focusNodes.length; i++) { + final idx = i; + _focusNodes[i].addListener(() { + final hasFocus = _focusNodes[idx].hasFocus; + if (_focusStates[idx] != hasFocus) { + setState(() => _focusStates[idx] = hasFocus); + } + }); + } + } + + void _disposeNodes() { + for (final node in _focusNodes) { + node.dispose(); + } + } + + @override + void dispose() { + _disposeNodes(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isKeyboard = InputModeTracker.isKeyboardMode(context); + final duration = FocusTheme.getAnimationDuration(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration), + ], + ); + } + + Widget _buildButton(int index, bool isKeyboard, Duration duration) { + final action = widget.actions[index]; + final isFocused = _focusStates[index]; + final showFocus = isFocused && isKeyboard; + final opacity = isKeyboard && !isFocused ? 0.6 : 1.0; + + return Focus( + focusNode: _focusNodes[index], + onKeyEvent: (node, event) { + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) return backResult; + } + return dpadKeyHandler( + onSelect: action.onPressed, + onLeft: index > 0 + ? () => _focusNodes[index - 1].requestFocus() + : widget.onNavigateLeft, + onRight: index < _focusNodes.length - 1 + ? () => _focusNodes[index + 1].requestFocus() + : widget.onNavigateRight, + onDown: widget.onNavigateDown, + onUp: widget.onNavigateUp, + )(node, event); + }, + child: AnimatedOpacity( + opacity: showFocus ? 1.0 : opacity, + duration: duration, + child: Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), + child: action.child ?? + IconButton( + icon: AppIcon(action.icon, fill: 1, color: action.iconColor), + tooltip: action.tooltip, + onPressed: action.onPressed, + ), + ), + ), + ); + } +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index b7968e5ef..4f9e4efef 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<CollectionD @override bool get hasItems => items.isNotEmpty; - @override - int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty) - @override void dispose() { disposeFocusResources(); @@ -69,23 +67,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD } @override - List<AppBarButtonConfig> getAppBarButtons() { - final buttons = <AppBarButtonConfig>[]; - if (items.isNotEmpty) { - buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems)); - buttons.add( - AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), - ); - } - buttons.add( - AppBarButtonConfig( - icon: Symbols.delete_rounded, - tooltip: t.common.delete, - onPressed: _deleteCollection, - color: Colors.red, - ), - ); - return buttons; + List<FocusableAction> getAppBarActions() { + return [ + if (items.isNotEmpty) ...[ + FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), + FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), + ], + FocusableAction(icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red), + ]; } Future<void> _deleteCollection() async { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 629b1148b..196be93b4 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<DiscoverScreen> // Hero and app bar focus late FocusNode _heroFocusNode; - late FocusNode _refreshButtonFocusNode; - late FocusNode _watchTogetherButtonFocusNode; - late FocusNode _companionRemoteButtonFocusNode; - late FocusNode _userButtonFocusNode; - bool _isRefreshFocused = false; - bool _isWatchTogetherFocused = false; - bool _isCompanionRemoteFocused = false; - bool _isUserFocused = false; + final _actionBarKey = GlobalKey<FocusableActionBarState>(); /// Get the correct PlexClient for an item's server PlexClient _getClientForItem(PlexMetadata? item) { @@ -188,7 +182,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> if (_isHeroSectionVisible) { _heroFocusNode.requestFocus(); } else { - _refreshButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } _scrollToTop(); } @@ -245,14 +239,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> super.initState(); WidgetsBinding.instance.addObserver(this); _heroFocusNode = FocusNode(debugLabel: 'hero_section'); - _refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button'); - _watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button'); - _companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button'); - _userButtonFocusNode = FocusNode(debugLabel: 'user_button'); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); - _watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange); - _companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange); - _userButtonFocusNode.addListener(_onUserFocusChange); _loadContent(); _startAutoScroll(); } @@ -272,37 +258,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> _loadContent(); } - void _onRefreshFocusChange() { - if (mounted) { - setState(() { - _isRefreshFocused = _refreshButtonFocusNode.hasFocus; - }); - } - } - - void _onWatchTogetherFocusChange() { - if (mounted) { - setState(() { - _isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus; - }); - } - } - - void _onCompanionRemoteFocusChange() { - if (mounted) { - setState(() { - _isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus; - }); - } - } - - void _onUserFocusChange() { - if (mounted) { - setState(() { - _isUserFocused = _userButtonFocusNode.hasFocus; - }); - } - } /// Handle key events for the hero section late final _handleHeroKeyEvent = dpadKeyHandler( @@ -310,7 +265,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> 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<DiscoverScreen> }, ); - /// Handle key events for the refresh button in app bar - late final _handleRefreshKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onRight: () => _watchTogetherButtonFocusNode.requestFocus(), - onLeft: _navigateToSidebar, - onUp: () {}, // Block at boundary - onSelect: _loadContent, - ); - - /// Handle key events for the watch together button in app bar - late final _handleWatchTogetherKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onLeft: () => _refreshButtonFocusNode.requestFocus(), - onRight: () => _companionRemoteButtonFocusNode.requestFocus(), - onUp: () {}, // Block at boundary - onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - ); - - /// Handle key events for the companion remote button in app bar - late final _handleCompanionRemoteKeyEvent = dpadKeyHandler( - onDown: () => _heroFocusNode.requestFocus(), - onLeft: () => _watchTogetherButtonFocusNode.requestFocus(), - onRight: () => _userButtonFocusNode.requestFocus(), - onUp: () {}, // Block at boundary - onSelect: () => RemoteSessionDialog.show(context), - ); - - /// Handle key events for the user button in app bar - late final _handleUserKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onLeft: () => _companionRemoteButtonFocusNode.requestFocus(), - onRight: () {}, // Block at boundary - onUp: () {}, // Block at boundary - onSelect: () { - final userProvider = context.read<UserProfileProvider>(); - _showUserMenu(context, userProvider); - }, - ); @override void dispose() { @@ -379,14 +296,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> _heroController.dispose(); _scrollController.dispose(); _heroFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); - _watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange); - _watchTogetherButtonFocusNode.dispose(); - _companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange); - _companionRemoteButtonFocusNode.dispose(); - _userButtonFocusNode.removeListener(_onUserFocusChange); - _userButtonFocusNode.dispose(); super.dispose(); } @@ -849,7 +758,10 @@ class _DiscoverScreenState extends State<DiscoverScreen> /// Show user menu programmatically (for D-pad select) void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { - final RenderBox? button = _userButtonFocusNode.context?.findRenderObject() as RenderBox?; + final actionBar = _actionBarKey.currentState; + if (actionBar == null) return; + final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1); + final RenderBox? button = lastNode.context?.findRenderObject() as RenderBox?; if (button == null) return; final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; @@ -916,171 +828,141 @@ class _DiscoverScreenState extends State<DiscoverScreen> ).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), ), const Spacer(), - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white), - onPressed: _loadContent, - ), - ), - ), - // Watch Together button - Consumer<WatchTogetherProvider>( - builder: (context, watchTogether, child) { - return Focus( - focusNode: _watchTogetherButtonFocusNode, - onKeyEvent: _handleWatchTogetherKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), + Consumer2<WatchTogetherProvider, CompanionRemoteProvider>( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.isDesktop(context); + final userProvider = context.watch<UserProfileProvider>(); + + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _focusContentFromAppBar, + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, + iconColor: Colors.white, + onPressed: _loadContent, ), - 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<CompanionRemoteProvider>( - builder: (context, companionRemote, child) { - final isDesktop = PlatformDetector.isDesktop(context); - final hasDpadNav = isDesktop || PlatformDetector.isTV(); - - return Focus( - focusNode: hasDpadNav ? _companionRemoteButtonFocusNode : null, - onKeyEvent: hasDpadNav ? _handleCompanionRemoteKeyEvent : null, - child: Container( - decoration: BoxDecoration( - color: hasDpadNav && _isCompanionRemoteFocused - ? Colors.white.withValues(alpha: 0.2) - : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.phone_android_rounded, - fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white, - ), - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); - } - }, - tooltip: t.companionRemote.title, - ), - // Badge showing connection status - if (companionRemote.isConnected) - Positioned( - top: 6, - right: 6, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)), - ), - ), - ), - ], - ), - ), - ); - }, - ), - Consumer<UserProfileProvider>( - builder: (context, userProvider, child) { - return Focus( - focusNode: _userButtonFocusNode, - onKeyEvent: _handleUserKeyEvent, - child: DecoratedBox( - decoration: BoxDecoration( - color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: PopupMenuButton<String>( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) - : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); + // Companion Remote + FocusableAction( + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => 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<String>( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) + : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + if (userProvider.hasMultipleUsers) + PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + AppIcon(Symbols.people_rounded, fill: 1), + 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 d28d63b29..a2ffa77fd 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<T extends StatefulWidget> on State<T>, GridFocu // Scroll controller for scrolling to top when app bar is focused final ScrollController scrollController = ScrollController(); - // App bar focus nodes - final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play'); - final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle'); - final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete'); + // Action bar key for accessing focus nodes + final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>(); // Grid item focus final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item'); // App bar focus state bool isAppBarFocused = false; - int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons) // Flag to prevent PopScope from exiting when BACK was handled by a key handler bool backHandledByKeyEvent = false; - /// Number of app bar buttons (override if different from 3) - int get appBarButtonCount => 3; - /// Called when items are available and we want to check if focus should be set bool get hasItems; - /// Called to get the list of app bar button configurations - List<AppBarButtonConfig> getAppBarButtons(); + /// Called to get the list of app bar action configurations + List<FocusableAction> getAppBarActions(); /// Dispose focus-related resources. Call this from your dispose() method. void disposeFocusResources() { scrollController.dispose(); - playButtonFocusNode.dispose(); - shuffleButtonFocusNode.dispose(); - deleteButtonFocusNode.dispose(); firstItemFocusNode.dispose(); disposeGridFocusNodes(); } @@ -67,9 +46,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu void navigateToAppBar() { setState(() { isAppBarFocused = true; - appBarFocusedButton = 0; }); - _focusAppBarButton(0); + actionBarKey.currentState?.getFocusNode(0).requestFocus(); // Scroll to top to show the app bar scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } @@ -115,103 +93,16 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu } } - /// Focus a specific app bar button by index - void _focusAppBarButton(int index) { - switch (index) { - case 0: - playButtonFocusNode.requestFocus(); - break; - case 1: - shuffleButtonFocusNode.requestFocus(); - break; - case 2: - deleteButtonFocusNode.requestFocus(); - break; - } - } - - /// Handle key events when app bar is focused - KeyEventResult handleAppBarKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - final maxButton = appBarButtonCount - 1; - - final backResult = handleBackKeyAction(event, () => Navigator.pop(context)); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - if (key.isLeftKey && appBarFocusedButton > 0) { - setState(() => appBarFocusedButton--); - _focusAppBarButton(appBarFocusedButton); - return KeyEventResult.handled; - } - if (key.isRightKey && appBarFocusedButton < maxButton) { - setState(() => appBarFocusedButton++); - _focusAppBarButton(appBarFocusedButton); - return KeyEventResult.handled; - } - if (key.isDownKey) { - // Return focus to grid - navigateToGrid(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - final buttons = getAppBarButtons(); - if (appBarFocusedButton < buttons.length) { - buttons[appBarFocusedButton].onPressed(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - /// Build focusable app bar action widgets List<Widget> buildFocusableAppBarActions() { - final colorScheme = Theme.of(context).colorScheme; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final buttons = getAppBarButtons(); - - return buttons.asMap().entries.map((entry) { - final index = entry.key; - final config = entry.value; - final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index; - - FocusNode focusNode; - switch (index) { - case 0: - focusNode = playButtonFocusNode; - break; - case 1: - focusNode = shuffleButtonFocusNode; - break; - case 2: - focusNode = deleteButtonFocusNode; - break; - default: - focusNode = FocusNode(); - } - - return Focus( - focusNode: focusNode, - onKeyEvent: handleAppBarKeyEvent, - child: Container( - decoration: isFocused - ? BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ) - : null, - child: IconButton( - icon: AppIcon(config.icon, fill: 1), - tooltip: config.tooltip, - onPressed: config.onPressed, - color: config.color, - ), - ), - ); - }).toList(); + return [ + FocusableActionBar( + key: actionBarKey, + onNavigateDown: navigateToGrid, + onBack: () => Navigator.pop(context), + actions: getAppBarActions(), + ), + ]; } /// Auto-focus first item after load if in keyboard mode. diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index ade17ca33..c9fff089a 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<HubDetailScreen> with Refreshable, Gri String? _errorMessage; late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item'); - late final FocusNode _sortButtonFocusNode = FocusNode(debugLabel: 'hub_detail_sort'); + final _actionBarKey = GlobalKey<FocusableActionBarState>(); bool _isAppBarFocused = false; bool _backHandledByKeyEvent = false; @@ -63,7 +63,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri @override void initState() { super.initState(); - _sortButtonFocusNode.addListener(_onSortButtonFocusChange); // Start with items already loaded in the hub _items = widget.hub.items; _filteredItems = widget.hub.items; @@ -84,23 +83,11 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri @override void dispose() { - _sortButtonFocusNode.removeListener(_onSortButtonFocusChange); _firstItemFocusNode.dispose(); - _sortButtonFocusNode.dispose(); disposeGridFocusNodes(); super.dispose(); } - void _onSortButtonFocusChange() { - if (!mounted) return; - final hasFocus = _sortButtonFocusNode.hasFocus; - if (hasFocus && !_isAppBarFocused) { - setState(() => _isAppBarFocused = true); - } else if (!hasFocus && _isAppBarFocused) { - setState(() => _isAppBarFocused = false); - } - } - void _focusGrid() { if (_filteredItems.isEmpty) return; final targetIndex = @@ -114,7 +101,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri void _navigateToAppBar() { setState(() => _isAppBarFocused = true); - _sortButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } void _handleBackFromContent() { @@ -122,24 +109,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri _navigateToAppBar(); } - KeyEventResult _handleSortButtonKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - final backResult = handleBackKeyAction(event, () => Navigator.pop(context)); - if (backResult != KeyEventResult.ignored) return backResult; - - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - if (key.isDownKey) { - _focusGrid(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - _showSortBottomSheet(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } Future<void> _loadSorts() async { try { @@ -315,7 +284,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri @override Widget build(BuildContext context) { final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final sortButtonFocused = isKeyboardMode && _isAppBarFocused; return PopScope( canPop: !isKeyboardMode || _isAppBarFocused, @@ -336,16 +304,17 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri title: Text(widget.hub.title), pinned: true, actions: [ - Focus( - focusNode: _sortButtonFocusNode, - onKeyEvent: _handleSortButtonKeyEvent, - child: Container( - decoration: FocusTheme.focusBackgroundDecoration(isFocused: sortButtonFocused, borderRadius: 20), - child: IconButton( - icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort), + FocusableActionBar( + key: _actionBarKey, + onNavigateDown: _focusGrid, + onBack: () => Navigator.pop(context), + actions: [ + FocusableAction( + icon: Symbols.swap_vert_rounded, + tooltip: t.libraries.sort, onPressed: _showSortBottomSheet, ), - ), + ], ), ], ), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1f0c77845..60aebdcfb 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<LibrariesScreen> _playlistsTabChipFocusNode, ]; - // App bar action button focus - late FocusNode _editButtonFocusNode; - late FocusNode _refreshButtonFocusNode; - bool _isEditFocused = false; - bool _isRefreshFocused = false; + // App bar action bar + final _actionBarKey = GlobalKey<FocusableActionBarState>(); // Scroll controller for the outer CustomScrollView final ScrollController _outerScrollController = ScrollController(); @@ -141,12 +138,6 @@ class _LibrariesScreenState extends State<LibrariesScreen> super.initState(); initTabNavigation(); - // Initialize action button focus nodes - _editButtonFocusNode = FocusNode(debugLabel: 'EditButton'); - _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); - _editButtonFocusNode.addListener(_onEditFocusChange); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); - // Initialize with libraries from the provider WidgetsBinding.instance.addPostFrameCallback((_) { _initializeWithLibraries(); @@ -338,42 +329,6 @@ class _LibrariesScreenState extends State<LibrariesScreen> _focusCurrentTab(); } - void _onEditFocusChange() { - if (mounted) { - setState(() => _isEditFocused = _editButtonFocusNode.hasFocus); - } - } - - void _onRefreshFocusChange() { - if (mounted) { - setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); - } - } - - /// Handle key events for the edit button in app bar - late final _handleEditKeyEvent = dpadKeyHandler( - onLeft: () => getTabChipFocusNode(3).requestFocus(), - onRight: () => _refreshButtonFocusNode.requestFocus(), - onDown: _focusCurrentTab, - onUp: () {}, // Block at boundary - onSelect: _showLibraryManagementSheet, - ); - - /// Handle key events for the refresh button in app bar - late final _handleRefreshKeyEvent = dpadKeyHandler( - onLeft: () { - final librariesProvider = context.read<LibrariesProvider>(); - if (librariesProvider.libraries.isNotEmpty) { - _editButtonFocusNode.requestFocus(); - } else { - getTabChipFocusNode(3).requestFocus(); - } - }, - onRight: () {}, // Block at boundary - onUp: () {}, // Block at boundary - onDown: _focusCurrentTab, - onSelect: _refreshCurrentTab, - ); @override void dispose() { @@ -383,10 +338,6 @@ class _LibrariesScreenState extends State<LibrariesScreen> _browseTabChipFocusNode.dispose(); _collectionsTabChipFocusNode.dispose(); _playlistsTabChipFocusNode.dispose(); - _editButtonFocusNode.removeListener(_onEditFocusChange); - _editButtonFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } @@ -935,13 +886,7 @@ class _LibrariesScreenState extends State<LibrariesScreen> getTabChipFocusNode(newIndex).requestFocus(); } : () { - // Navigate to first action button (edit if libraries exist, else refresh) - final librariesProvider = context.read<LibrariesProvider>(); - if (librariesProvider.libraries.isNotEmpty) { - _editButtonFocusNode.requestFocus(); - } else { - _refreshButtonFocusNode.requestFocus(); - } + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); }, onNavigateDown: _focusCurrentTabFromTabBar, onBack: onTabBarBack, @@ -1048,36 +993,23 @@ class _LibrariesScreenState extends State<LibrariesScreen> shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - if (allLibraries.isNotEmpty) - Focus( - focusNode: _editButtonFocusNode, - onKeyEvent: _handleEditKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.edit_rounded, fill: 1), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + if (allLibraries.isNotEmpty) + FocusableAction( + icon: Symbols.edit_rounded, tooltip: t.libraries.manageLibraries, onPressed: _showLibraryManagementSheet, ), - ), - ), - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), + FocusableAction( + icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshCurrentTab, ), - ), + ], ), ], ), diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index e9dedf88c..7e1e24aee 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<LiveTvScreen> final _guideTabKey = GlobalKey<GuideTabState>(); final _whatsOnTabKey = GlobalKey<WhatsOnTabState>(); - // App bar action button focus - final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); - bool _isRefreshFocused = false; + // App bar action bar + final _actionBarKey = GlobalKey<FocusableActionBarState>(); List<LiveTvChannel> _channels = []; bool _isLoading = true; @@ -47,7 +47,6 @@ class _LiveTvScreenState extends State<LiveTvScreen> super.initState(); suppressAutoFocus = true; initTabNavigation(); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); _loadChannels(); } @@ -55,15 +54,10 @@ class _LiveTvScreenState extends State<LiveTvScreen> void dispose() { _guideTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } - void _onRefreshFocusChange() { - if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); - } @override void onTabChanged() { @@ -208,34 +202,6 @@ class _LiveTvScreenState extends State<LiveTvScreen> @override void focusActiveTabIfReady() => _focusCurrentTab(); - // --------------------------------------------------------------------------- - // Action button key handlers - // --------------------------------------------------------------------------- - - KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) { - if (!event.isActionable) return KeyEventResult.ignored; - final key = event.logicalKey; - - if (key.isLeftKey) { - getTabChipFocusNode(tabCount - 1).requestFocus(); - return KeyEventResult.handled; - } - if (key.isRightKey) { - return KeyEventResult.handled; - } - if (key.isDownKey) { - _focusCurrentTab(); - return KeyEventResult.handled; - } - if (key.isUpKey) { - return KeyEventResult.handled; - } - if (key.isSelectKey) { - _loadChannels(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } // --------------------------------------------------------------------------- // Tab chips @@ -276,7 +242,7 @@ class _LiveTvScreenState extends State<LiveTvScreen> }); getTabChipFocusNode(newIndex).requestFocus(); } - : () => _refreshButtonFocusNode.requestFocus(), + : () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); @@ -303,20 +269,17 @@ class _LiveTvScreenState extends State<LiveTvScreen> ) : Text(t.liveTv.title), actions: [ - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, tooltip: t.liveTv.reloadGuide, onPressed: _loadChannels, ), - ), + ], ), ], ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 69cca68e3..a23d594a1 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<PlaylistDetai bool get hasItems => items.isNotEmpty; @override - int get appBarButtonCount { - int count = 0; - if (items.isNotEmpty) count += 2; // play + shuffle - if (!widget.playlist.smart) count += 1; // delete - return count; - } - - @override - List<AppBarButtonConfig> getAppBarButtons() { - final buttons = <AppBarButtonConfig>[]; - if (items.isNotEmpty) { - buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems)); - buttons.add( - AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), - ); - } - if (!widget.playlist.smart) { - buttons.add( - AppBarButtonConfig( - icon: Symbols.delete_rounded, - tooltip: t.playlists.delete, - onPressed: _deletePlaylist, - color: Colors.red, - ), - ); - } - return buttons; + List<FocusableAction> getAppBarActions() { + return [ + if (items.isNotEmpty) ...[ + FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), + FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), + ], + if (!widget.playlist.smart) + FocusableAction(icon: Symbols.delete_rounded, tooltip: t.playlists.delete, onPressed: _deletePlaylist, iconColor: Colors.red), + ]; } // Focus management for regular (non-smart) reorderable lists diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 6dff4316f..ed08666b7 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<LogsScreen> { List<LogEntry> _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<LogsScreen> { 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<LogsScreen> { 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 60aebdcfb..f65155733 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 000000000..20fa9fc2f --- /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 000000000..affbca999 --- /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 20fa9fc2f..e47a619e6 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 affbca999..fb6c48630 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 fdfbba20c..310e31434 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 cbc18e9ef..eb9388890 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 7e80021f4..bfe8e54cb 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 9a0f1315f..b81f1c233 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 b1da29262..8e9f5558d 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 1fcca5ef8..0450b81b6 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 e08757ae1..3ff7e5637 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 e89a84e9c..55049c72f 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 285c991e7..09a1d73da 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 2b36a0ddd..4824a5ea1 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 79dac6a84..45e2a918c 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 96039f0ce..d49f792c5 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 891b5017e..24936b1e7 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 fc2ca4055..d56dd4b54 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 735b8ab46..8c1d34c50 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 013cfa06f..a877e8448 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 9a0761e9b..c66ac4d81 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 5b08f62c5..3349eb43a 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 02bb4efc1..fc6a62053 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<bool> showDeleteConfirmation(BuildContext context, {required String title, required String message}) { - return showConfirmDialog(context, title: title, message: message, confirmText: t.common.delete, isDestructive: true); +Future<bool> 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 d0228570e..985b675a7 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<FocusableListTile> { 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 be75d9e26..3077bc435 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<MediaContextMenu> { 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<MediaContextMenu> { // 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 5b32221c0..ab2bea746 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<String, dynamic> metadata) { + final media = metadata['Media'] as List<dynamic>?; + if (media == null || media.isEmpty) return null; + final parts = media[0]['Part'] as List<dynamic>?; + if (parts == null || parts.isEmpty) return null; + final streams = parts[0]['Stream'] as List<dynamic>?; + + final audioTracks = <PlexAudioTrack>[]; + final subtitleTracks = <PlexSubtitleTrack>[]; + + if (streams != null) { + for (final s in streams) { + 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 bba466923..db002de21 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<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin return context.getClientForServer(widget.metadata.serverId!); } - String? _buildThumbnailUrl(BuildContext context, Duration time) { - final partId = _currentMediaInfo?.partId; - if (partId == null || widget.isOffline) return null; - final client = _getClientForMetadata(context); - return '${client.config.baseUrl}/library/parts/$partId/indexes/sd/${time.inMilliseconds}'.withPlexToken( - client.config.token, - ); - } + Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time); final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered @@ -1026,17 +1021,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin setState(() { _availableVersions = result.availableVersions.cast(); _currentMediaInfo = result.mediaInfo; - _hasThumbnails = false; + _bifService?.dispose(); + _bifService = null; }); - // Check whether any thumbnails exist by requesting the first one + // Download and cache BIF thumbnail file if (_currentMediaInfo?.partId != null && !widget.isOffline) { final partId = _currentMediaInfo!.partId!; final client = _getClientForMetadata(context); - client.checkThumbnailsAvailable(partId).then((available) { - // Guard against media having changed while the probe was in flight + final service = BifThumbnailService(); + service.load(client, partId).then((_) { + // Guard against media having changed while the download was in flight if (mounted && _currentMediaInfo?.partId == partId) { - setState(() => _hasThumbnails = available); + setState(() => _bifService = service); + } else { + service.dispose(); } }); } @@ -1094,10 +1093,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin } } on PlaybackException catch (e) { if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error showErrorSnackBar(context, e.message); } } catch (e) { if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } @@ -1142,10 +1143,28 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin appLogger.d('Starting offline playback: $videoPath'); + // Load cached media info so track selection (audio language) works offline + PlexMediaInfo? mediaInfo; + try { + final serverId = widget.metadata.serverId; + if (serverId != null) { + final cached = await PlexApiCache.instance.get( + serverId, + '/library/metadata/${widget.metadata.ratingKey}', + ); + final metadataJson = PlexCacheParser.extractFirstMetadata(cached); + if (metadataJson != null) { + mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson); + } + } + } 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<VideoPlayerScreen> with WidgetsBindin _videoPIPManager?.onBeforeEnterPip = null; _videoFilterManager?.dispose(); + // Release cached BIF thumbnail data + _bifService?.dispose(); + // Mark sleep timer for restart if truly exiting (not episode transition) if (!_isReplacingWithVideo) { SleepTimerService().markNeedsRestart(); @@ -2505,9 +2527,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin shaderService: _shaderService, // ignore: no-empty-block - setState triggers rebuild to reflect shader change onShaderChanged: () => setState(() {}), - thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null - ? (Duration time) => _buildThumbnailUrl(context, time)! - : null, + thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null, isLive: widget.isLive, liveChannelName: _liveChannelName, isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 4ca77b818..7bf3573c6 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -369,15 +369,17 @@ class DownloadStorageService { Future<String> 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 ed08666b7..8c83ccce3 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -124,7 +124,7 @@ class _LogsScreenState extends State<LogsScreen> { icon: const Icon(Icons.copy, size: 20), onPressed: () { Clipboard.setData(ClipboardData(text: id)); - showSuccessSnackBar(ctx, t.messages.logsCopied); + showSuccessSnackBar(context, t.messages.logsCopied); }, ), ], 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 000000000..cc616f3e6 --- /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<BifEntry> _parseBifBytes(Uint8List bytes) { + if (bytes.length < 64) return []; + + final data = ByteData.sublistView(bytes); + + // Validate magic: 0x89 B I F 0x0D 0x0A 0x1A 0x0A + const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A]; + for (var i = 0; i < magic.length; i++) { + if (bytes[i] != magic[i]) return []; + } + + final imageCount = data.getUint32(12, Endian.little); + var timestampMultiplier = data.getUint32(16, Endian.little); + if (timestampMultiplier == 0) timestampMultiplier = 1000; + + // Index table starts at byte 64; each entry is 8 bytes. + // There are (imageCount + 1) entries (last is sentinel). + final indexTableSize = (imageCount + 1) * 8; + if (bytes.length < 64 + indexTableSize) return []; + + final entries = <BifEntry>[]; + for (var i = 0; i < imageCount; i++) { + final entryOffset = 64 + i * 8; + final timestamp = data.getUint32(entryOffset, Endian.little); + final imgOffset = data.getUint32(entryOffset + 4, Endian.little); + + // Next entry's offset gives us the end of this image's data. + final nextEntryOffset = 64 + (i + 1) * 8; + final nextImgOffset = data.getUint32(nextEntryOffset + 4, Endian.little); + + if (nextImgOffset <= imgOffset || nextImgOffset > bytes.length) continue; + + entries.add(( + timestampMs: timestamp * timestampMultiplier, + imageBytes: Uint8List.sublistView(bytes, imgOffset, nextImgOffset), + )); + } + + return entries; +} + +/// Caches a full BIF file in memory and serves thumbnails by timestamp. +class BifThumbnailService { + List<BifEntry>? _entries; + + /// Download and parse the BIF file for [partId]. + /// Returns silently on failure (thumbnails simply won't be available). + Future<void> load(PlexClient client, int partId) async { + _entries = null; + try { + final bytes = await client.downloadBifFile(partId); + if (bytes == null || bytes.isEmpty) return; + _entries = await Isolate.run(() => _parseBifBytes(bytes)); + } catch (e) { + appLogger.w('BIF download/parse failed', error: e); + } + } + + /// Whether thumbnails have been loaded successfully. + bool get isAvailable => _entries != null && _entries!.isNotEmpty; + + /// Return the JPEG bytes for the thumbnail nearest to [time]. + /// Uses binary search for O(log n) lookup. + Uint8List? getThumbnail(Duration time) { + final entries = _entries; + if (entries == null || entries.isEmpty) return null; + + final ms = time.inMilliseconds; + + // Binary search for the largest timestamp <= ms. + var lo = 0; + var hi = entries.length - 1; + while (lo < hi) { + final mid = (lo + hi + 1) ~/ 2; // bias right + if (entries[mid].timestampMs <= ms) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return entries[lo].imageBytes; + } + + /// Release cached data. + void dispose() { + _entries = null; + } +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7aac83c8c..6d3d4705c 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<bool> 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<Uint8List?> 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<List<int>>( + '/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 74880b437..a74fc2b2e 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<DesktopVideoControls> { 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 a98e6fff9..2973f989d 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<bool>? 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 d4a0623fc..b5853d7b6 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<bool>? 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<PlexVideoControls> 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<PlexVideoControls> 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 ec251858f..c2362809e 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<TimelineSlider> { // 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<TimelineSlider> { } 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<TimelineSlider> { 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<TimelineSlider> { 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 1447a1894..64e17bd1b 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 cff9e81b1..4e6ac70e4 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 dec725305..8dd3fb54e 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<SetupScreen> { // 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<SetupScreen> { _setStatus(t.common.startingOfflineMode); await context.read<DownloadProvider>().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<SetupScreen> { 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<DownloadProvider>().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<SetupScreen> { _setStatus(t.common.startingOfflineMode); await context.read<DownloadProvider>().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<SetupScreen> { 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<SetupScreen> { _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 508b0ad1a..dd9ba9864 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -113,10 +113,7 @@ class _AuthScreenState extends State<AuthScreen> { 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<AuthScreen> { padding: const EdgeInsets.symmetric(vertical: 12), side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)), ), - child: Text(t.auth.debugEnterToken, style: TextStyle(fontSize: 12)), + child: Text(t.auth.debugEnterToken, style: const TextStyle(fontSize: 12)), ), ], if (_errorMessage != null) ...[ diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 5c51dae4c..8d3db5d78 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 196be93b4..e0ca48820 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -258,7 +258,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> _loadContent(); } - /// Handle key events for the hero section late final _handleHeroKeyEvent = dpadKeyHandler( onDown: () { @@ -285,7 +284,6 @@ class _DiscoverScreenState extends State<DiscoverScreen> }, ); - @override void dispose() { _hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged); @@ -781,12 +779,18 @@ class _DiscoverScreenState extends State<DiscoverScreen> PopupMenuItem( value: 'switch_profile', child: Row( - children: [AppIcon(Symbols.people_rounded, fill: 1), SizedBox(width: 8), Text(t.discover.switchProfile)], + children: [ + AppIcon(Symbols.people_rounded, fill: 1), + const SizedBox(width: 8), + Text(t.discover.switchProfile), + ], ), ), PopupMenuItem( value: 'logout', - child: Row(children: [AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), Text(t.common.logout)]), + child: Row( + children: [AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)], + ), ), ], ).then((value) { @@ -838,14 +842,11 @@ class _DiscoverScreenState extends State<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> 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<DiscoverScreen> child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), - SizedBox(height: 16), + const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), + const SizedBox(height: 16), Text(t.discover.noContentAvailable), - SizedBox(height: 8), - Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)), + const SizedBox(height: 8), + Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)), ], ), ), @@ -1575,7 +1587,7 @@ class _DiscoverScreenState extends State<DiscoverScreen> ] else Text( t.common.play, - style: TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600), + style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600), ), ], ), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index a2ffa77fd..d58825b6e 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 c9fff089a..ac0c288b8 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 7e1e24aee..a16db00ca 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 db9cda78c..a0831cf7b 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<GuideTab> { 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 a80565927..e10e3c894 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 9ecc5f592..aba5376ef 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -235,16 +235,16 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre : null, filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(100)), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), borderSide: BorderSide.none, ), - enabledBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(100)), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), borderSide: BorderSide.none, ), - focusedBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(100)), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 13ad80918..2fc851630 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -59,7 +59,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> bool _suppressNextBackKeyUp = false; bool _routeSubscribed = false; - String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey); + String _toGlobalKey(String ratingKey, {String? serverId}) => + buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey); // WatchStateAware: watch all episode ratingKeys @override @@ -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 8c83ccce3..edbabbe50 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 ef7150995..e6c51f48d 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -51,10 +51,9 @@ class PlexAuthService { static Future<PlexAuthService> 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<String, dynamic> 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<String, dynamic> 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 4b830db93..3fd86334a 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<String, HotKey> 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 b20968881..dffff9397 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 a74fc2b2e..7b905984a 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<DesktopVideoControls> { 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 2973f989d..f59584cdd 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 8b6edbf97..885562321 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<ChapterSheet> { - /// 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<ChapterSheet> { @override Widget build(BuildContext context) { return StreamBuilder<Duration>( - 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<ChapterSheet> { 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 4a3a22283..ec65b7529 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 b5853d7b6..25d998cfe 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<PlexVideoControls> 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<PlexVideoControls> 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 c2362809e..ac3ebf05e 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<TimelineSlider> { 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 37b7b3cc8..9c7d9fe78 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 dd04e7d5d..7ecb33244 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 dd04e7d5d..7ecb33244 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 c02af0246..daf8bf7e1 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 93c870f8d..7ecb33244 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 93c870f8d..7ecb33244 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 d3dda629e..876aa360d 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 ab2bea746..1c92a2191 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 db002de21..ed0ea2d52 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1156,6 +1156,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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 4e6ac70e4..b73b97b79 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 7bf3573c6..1841a0280 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<String> 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 ed0ea2d52..5b436a855 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -421,6 +421,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin '#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor', ); await player!.setProperty('sub-ass-override', 'no'); + await player!.setProperty('sub-ass-video-aspect-override', '1'); await player!.setProperty('sub-pos', settingsService.getSubtitlePosition().toString()); // Platform-specific settings 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 5bd490ac9..4bdb1165c 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<String, Int>() @@ -66,6 +67,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, mpvCore?.dispose() mpvCore = null usingMpvFallback = false + fallbackInProgress = false activity = null activityBinding = null Log.d(TAG, "Detached from activity") @@ -169,14 +171,12 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleDispose(result: MethodChannel.Result) { activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.dispose() - mpvCore = null - } else { - playerCore?.dispose() - playerCore = null - } + playerCore?.dispose() + playerCore = null + mpvCore?.dispose() + mpvCore = null usingMpvFallback = false + fallbackInProgress = false Log.d(TAG, "Disposed") result.success(null) } ?: result.success(null) @@ -571,7 +571,13 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, positionMs: Long, errorMessage: String ): Boolean { + if (usingMpvFallback || fallbackInProgress) { + Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request") + return true + } + val currentActivity = activity ?: return false + fallbackInProgress = true Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") @@ -580,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 158af5513..f0e836148 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<ViewGroup>(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 5b436a855..67a54592a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -140,6 +140,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; + bool _isApplyingTrackSelection = false; bool _isHandlingBack = false; BifThumbnailService? _bifService; @@ -552,6 +553,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin // Listen to position for completion detection (fallback for unreliable MPV events) _positionSubscription = player!.streams.position.listen((position) { + // Fallback for cases where playbackRestart doesn't fire (observed on some + // offline Android playback flows). Prevents a permanent loading spinner. + if (!_hasFirstFrame.value && position.inMilliseconds > 0) { + _hasFirstFrame.value = true; + } + final duration = player!.state.duration; if (duration.inMilliseconds > 0 && position.inMilliseconds >= duration.inMilliseconds - 1000 && @@ -1149,16 +1156,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> with WidgetsBindin } } + /// Wait briefly for profile settings to load in offline mode. + /// This prevents default-track fallback when playback starts before + /// UserProfileProvider finishes initialization. + Future<void> _waitForProfileSettingsIfNeeded() async { + if (!widget.isOffline || !mounted) return; + + final provider = context.read<UserProfileProvider>(); + if (provider.profileSettings != null) return; + + final completer = Completer<void>(); + late VoidCallback listener; + listener = () { + if (provider.profileSettings != null && !completer.isCompleted) { + completer.complete(); + } + }; + + provider.addListener(listener); + try { + await Future.any<void>([completer.future, Future.delayed(const Duration(seconds: 2))]); + } finally { + provider.removeListener(listener); + } + } + /// Apply track selection using the TrackSelectionService Future<void> _applyTrackSelection() async { - if (!mounted || player == null) return; + if (!mounted || player == null || _isApplyingTrackSelection) return; - final profileSettings = context.read<UserProfileProvider>().profileSettings; - final settingsService = await SettingsService.getInstance(); - final trackService = TrackSelectionService( - player: player!, - profileSettings: profileSettings, - metadata: widget.metadata, - plexMediaInfo: _currentMediaInfo, - ); + _isApplyingTrackSelection = true; + try { + await _waitForProfileSettingsIfNeeded(); + if (!mounted || player == null) return; - await trackService.selectAndApplyTracks( - preferredAudioTrack: widget.preferredAudioTrack, - preferredSubtitleTrack: widget.preferredSubtitleTrack, - defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - ); + final profileSettings = context.read<UserProfileProvider>().profileSettings; + final settingsService = await SettingsService.getInstance(); + if (!mounted || player == null) return; + + final trackService = TrackSelectionService( + player: player!, + profileSettings: profileSettings, + metadata: widget.metadata, + plexMediaInfo: _currentMediaInfo, + ); + + await trackService.selectAndApplyTracks( + preferredAudioTrack: widget.preferredAudioTrack, + preferredSubtitleTrack: widget.preferredSubtitleTrack, + defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(), + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + ); + } catch (e) { + appLogger.w('Failed to apply track selection', error: e); + } finally { + _isApplyingTrackSelection = false; + } } /// Rating key used for series/movie level language preferences. diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 1841a0280..b3e10bf38 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<String> ensureAbsolutePath(String storedPath) async { appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}'); + final baseDir = await _getBaseAppDir(); + final normalizedCandidates = <String>[]; - 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 a4582ff9a..7feb3aab6 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 ee8ffa930..d8c6e5bed 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 48033a142..47c18a46f 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<MediaDetailScreen> with WatchStateAware, DeletionAware { List<PlexMetadata> _seasons = []; bool _isLoadingSeasons = false; + Completer<void>? _seasonsCompleter; PlexMetadata? _fullMetadata; PlexMetadata? _onDeckEpisode; PlexVideoPlaybackData? _playbackData; @@ -1025,6 +1026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw } Future<void> _loadSeasons() async { + _seasonsCompleter = Completer<void>(); setState(() { _isLoadingSeasons = true; }); @@ -1048,11 +1050,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw setState(() { _isLoadingSeasons = false; }); + } finally { + if (!(_seasonsCompleter?.isCompleted ?? true)) { + _seasonsCompleter?.complete(); + } } } /// Load seasons from downloaded episodes (offline mode) void _loadSeasonsFromDownloads() { + _seasonsCompleter = Completer<void>(); setState(() { _isLoadingSeasons = true; }); @@ -1087,6 +1094,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw _seasons = seasons; _isLoadingSeasons = false; }); + if (!(_seasonsCompleter?.isCompleted ?? true)) { + _seasonsCompleter?.complete(); + } } /// Load extras (trailers, behind-the-scenes, etc.) @@ -1654,8 +1664,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw } // Wait for seasons to finish loading if they're currently loading - while (_isLoadingSeasons) { - await Future.delayed(const Duration(milliseconds: 100)); + if (_isLoadingSeasons && _seasonsCompleter != null) { + await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {}); } if (!mounted) return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 67a54592a..6d2c0e905 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -173,6 +173,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin // Screen-level focus node: persists across loading/initialized phases so // key events never escape the video player route. late final FocusNode _screenFocusNode; + bool _reclaimingFocus = false; // Cached setting: when false on Windows/Linux, ESC should not exit the player bool _videoPlayerNavigationEnabled = false; @@ -1723,8 +1724,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin /// descendant has focus, so internal movement between child controls /// does NOT trigger this. void _onScreenFocusChanged() { + if (_reclaimingFocus) return; if (!_screenFocusNode.hasFocus && mounted && !_isExiting.value) { + _reclaimingFocus = true; WidgetsBinding.instance.addPostFrameCallback((_) { + _reclaimingFocus = false; if (mounted && !_isExiting.value && !_screenFocusNode.hasFocus) { _screenFocusNode.requestFocus(); } 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 25d998cfe..8bab6f36a 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> with WindowListen widget.onSeekCompleted?.call(endTime); } _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); } void _startAutoSkipTimer(PlexMarker marker) { @@ -460,6 +472,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<void> _loadSeekTimes() async { @@ -570,6 +604,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen _hideTimer?.cancel(); _feedbackTimer?.cancel(); _autoSkipTimer?.cancel(); + _skipButtonDismissTimer?.cancel(); _singleTapTimer?.cancel(); _seekThrottle.cancel(); _playingSubscription?.cancel(); @@ -653,7 +688,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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 e9ebd435f..ad83b0bf5 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 = <String, String>{'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 = <ConnectionTestResult>[]; @@ -286,7 +284,6 @@ class PlexClient { token, timeout: timeout, clientIdentifier: clientIdentifier, - appName: appName, ); // If any attempt fails, return failed result immediately