diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 4d50a016..869f6225 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -46,7 +46,8 @@ "settings": "Einstellungen", "mute": "Stumm", "ok": "OK", - "loading": "Laden..." + "loading": "Laden...", + "reconnect": "Erneut verbinden" }, "screens": { "licenses": "Lizenzen", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 76269b14..b1a80f8c 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -46,7 +46,8 @@ "settings": "Settings", "mute": "Mute", "ok": "OK", - "loading": "Loading..." + "loading": "Loading...", + "reconnect": "Reconnect" }, "screens": { "licenses": "Licenses", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 25a77486..9ce4ff85 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -46,7 +46,8 @@ "settings": "Ajustes", "mute": "Silencio", "ok": "OK", - "loading": "Cargando..." + "loading": "Cargando...", + "reconnect": "Reconectar" }, "screens": { "licenses": "Licencias", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index d3a603c0..f7e07ba6 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -46,7 +46,8 @@ "settings": "Paramètres", "mute": "Muet", "ok": "OK", - "loading": "Chargement..." + "loading": "Chargement...", + "reconnect": "Reconnecter" }, "screens": { "licenses": "Licenses", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 6741c342..9175fe71 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -46,7 +46,8 @@ "settings": "Impostazioni", "mute": "Muto", "ok": "OK", - "loading": "Caricamento..." + "loading": "Caricamento...", + "reconnect": "Riconnetti" }, "screens": { "licenses": "Licenze", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e6f73655..c77a3e19 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -46,7 +46,8 @@ "settings": "설정", "mute": "음소거", "ok": "확인", - "loading": "로딩 중..." + "loading": "로딩 중...", + "reconnect": "다시 연결" }, "screens": { "licenses": "라이선스", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f4a651fb..f2cc57fb 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -46,7 +46,8 @@ "settings": "Instellingen", "mute": "Dempen", "ok": "OK", - "loading": "Laden..." + "loading": "Laden...", + "reconnect": "Opnieuw verbinden" }, "screens": { "licenses": "Licenties", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 9aedac5f..6c0845ba 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 6219 (691 per locale) +/// Strings: 6228 (692 per locale) /// -/// Built on 2026-02-11 at 13:27 UTC +/// Built on 2026-02-14 at 20:37 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 5cbbf0fe..37e06390 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonDe implements TranslationsCommonEn { @override String get mute => 'Stumm'; @override String get ok => 'OK'; @override String get loading => 'Laden...'; + @override String get reconnect => 'Erneut verbinden'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsDe { 'common.mute' => 'Stumm', 'common.ok' => 'OK', 'common.loading' => 'Laden...', + 'common.reconnect' => 'Erneut verbinden', 'screens.licenses' => 'Lizenzen', 'screens.switchProfile' => 'Profil wechseln', 'screens.subtitleStyling' => 'Untertitel-Stil', @@ -1679,9 +1681,9 @@ extension on TranslationsDe { 'playlists.errorRemoving' => 'Konnte nicht aus der Wiedergabeliste entfernt werden', 'playlists.playlist' => 'Wiedergabeliste', 'collections.title' => 'Sammlungen', - 'collections.collection' => 'Sammlung', _ => null, } ?? switch (path) { + 'collections.collection' => 'Sammlung', 'collections.empty' => 'Sammlung ist leer', 'collections.unknownLibrarySection' => 'Löschen nicht möglich: Unbekannte Bibliothekssektion', 'collections.deleteCollection' => 'Sammlung löschen', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 51b135e5..f668c94b 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -232,6 +232,9 @@ class TranslationsCommonEn { /// en: 'Loading...' String get loading => 'Loading...'; + + /// en: 'Reconnect' + String get reconnect => 'Reconnect'; } // Path: screens @@ -2600,6 +2603,7 @@ extension on Translations { 'common.mute' => 'Mute', 'common.ok' => 'OK', 'common.loading' => 'Loading...', + 'common.reconnect' => 'Reconnect', 'screens.licenses' => 'Licenses', 'screens.switchProfile' => 'Switch Profile', 'screens.subtitleStyling' => 'Subtitle Styling', @@ -3068,9 +3072,9 @@ extension on Translations { 'playlists.errorCreating' => 'Failed to create playlist', 'playlists.errorDeleting' => 'Failed to delete playlist', 'playlists.errorLoading' => 'Failed to load playlists', - 'playlists.errorAdding' => 'Failed to add to playlist', _ => null, } ?? switch (path) { + 'playlists.errorAdding' => 'Failed to add to playlist', 'playlists.errorReordering' => 'Failed to reorder playlist item', 'playlists.errorRemoving' => 'Failed to remove from playlist', 'watchTogether.title' => 'Watch Together', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index ef065fea..8edc95f9 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonEs implements TranslationsCommonEn { @override String get mute => 'Silencio'; @override String get ok => 'OK'; @override String get loading => 'Cargando...'; + @override String get reconnect => 'Reconectar'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsEs { 'common.mute' => 'Silencio', 'common.ok' => 'OK', 'common.loading' => 'Cargando...', + 'common.reconnect' => 'Reconectar', 'screens.licenses' => 'Licencias', 'screens.switchProfile' => 'Cambiar Perfil', 'screens.subtitleStyling' => 'Estilo de Subtítulos', @@ -1679,9 +1681,9 @@ extension on TranslationsEs { 'playlists.errorCreating' => 'Error al crear la lista', 'playlists.errorDeleting' => 'Error al eliminar la lista', 'playlists.errorLoading' => 'Error al cargar las listas', - 'playlists.errorAdding' => 'Error al añadir a la lista', _ => null, } ?? switch (path) { + 'playlists.errorAdding' => 'Error al añadir a la lista', 'playlists.errorReordering' => 'Error al reordenar los elementos de la lista', 'playlists.errorRemoving' => 'Error al eliminar de la lista', 'watchTogether.title' => 'Ver Juntos', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 7f6d1ee3..00b556b3 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonFr implements TranslationsCommonEn { @override String get mute => 'Muet'; @override String get ok => 'OK'; @override String get loading => 'Chargement...'; + @override String get reconnect => 'Reconnecter'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsFr { 'common.mute' => 'Muet', 'common.ok' => 'OK', 'common.loading' => 'Chargement...', + 'common.reconnect' => 'Reconnecter', 'screens.licenses' => 'Licenses', 'screens.switchProfile' => 'Changer de profil', 'screens.subtitleStyling' => 'Configuration des sous-titres', @@ -1679,9 +1681,9 @@ extension on TranslationsFr { 'playlists.errorCreating' => 'Échec de la création de playlist', 'playlists.errorDeleting' => 'Échec de suppression de playlist', 'playlists.errorLoading' => 'Échec de chargement de playlists', - 'playlists.errorAdding' => 'Échec d\'ajout dans la playlist', _ => null, } ?? switch (path) { + 'playlists.errorAdding' => 'Échec d\'ajout dans la playlist', 'playlists.errorReordering' => 'Échec de réordonnacement d\'élément de playlist', 'playlists.errorRemoving' => 'Échec de suppression depuis la playlist', 'watchTogether.title' => 'Regarder ensemble', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index eca8118a..1a926403 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonIt implements TranslationsCommonEn { @override String get mute => 'Muto'; @override String get ok => 'OK'; @override String get loading => 'Caricamento...'; + @override String get reconnect => 'Riconnetti'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsIt { 'common.mute' => 'Muto', 'common.ok' => 'OK', 'common.loading' => 'Caricamento...', + 'common.reconnect' => 'Riconnetti', 'screens.licenses' => 'Licenze', 'screens.switchProfile' => 'Cambia profilo', 'screens.subtitleStyling' => 'Stile sottotitoli', @@ -1679,9 +1681,9 @@ extension on TranslationsIt { 'playlists.errorRemoving' => 'Errore durante la rimozione dalla playlist', 'playlists.playlist' => 'Playlist', 'collections.title' => 'Raccolte', - 'collections.collection' => 'Raccolta', _ => null, } ?? switch (path) { + 'collections.collection' => 'Raccolta', 'collections.empty' => 'La raccolta è vuota', 'collections.unknownLibrarySection' => 'Impossibile eliminare: sezione libreria sconosciuta', 'collections.deleteCollection' => 'Elimina raccolta', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index e23a0461..50d239b2 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonKo implements TranslationsCommonEn { @override String get mute => '음소거'; @override String get ok => '확인'; @override String get loading => '로딩 중...'; + @override String get reconnect => '다시 연결'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsKo { 'common.mute' => '음소거', 'common.ok' => '확인', 'common.loading' => '로딩 중...', + 'common.reconnect' => '다시 연결', 'screens.licenses' => '라이선스', 'screens.switchProfile' => '프로필 전환', 'screens.subtitleStyling' => '자막 스타일 설정', @@ -1679,9 +1681,9 @@ extension on TranslationsKo { 'playlists.errorCreating' => '재생 목록 생성 실패', 'playlists.errorDeleting' => '재생 목록 삭제 실패', 'playlists.errorLoading' => '재생 목록 로드 실패', - 'playlists.errorAdding' => '재생 목록에 추가 실패', _ => null, } ?? switch (path) { + 'playlists.errorAdding' => '재생 목록에 추가 실패', 'playlists.errorReordering' => '재생 목록 항목 재정렬 실패', 'playlists.errorRemoving' => '재생 목록에서 제거 실패', 'watchTogether.title' => '함께 보기', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 53b5585d..89451ef9 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonNl implements TranslationsCommonEn { @override String get mute => 'Dempen'; @override String get ok => 'OK'; @override String get loading => 'Laden...'; + @override String get reconnect => 'Opnieuw verbinden'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsNl { 'common.mute' => 'Dempen', 'common.ok' => 'OK', 'common.loading' => 'Laden...', + 'common.reconnect' => 'Opnieuw verbinden', 'screens.licenses' => 'Licenties', 'screens.switchProfile' => 'Wissel van profiel', 'screens.subtitleStyling' => 'Ondertitel opmaak', @@ -1679,9 +1681,9 @@ extension on TranslationsNl { 'playlists.errorRemoving' => 'Fout bij verwijderen uit afspeellijst', 'playlists.playlist' => 'Afspeellijst', 'collections.title' => 'Collecties', - 'collections.collection' => 'Collectie', _ => null, } ?? switch (path) { + 'collections.collection' => 'Collectie', 'collections.empty' => 'Collectie is leeg', 'collections.unknownLibrarySection' => 'Kan niet verwijderen: onbekende bibliotheeksectie', 'collections.deleteCollection' => 'Collectie verwijderen', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 638329d1..e948b574 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonSv implements TranslationsCommonEn { @override String get mute => 'Ljud av'; @override String get ok => 'OK'; @override String get loading => 'Laddar...'; + @override String get reconnect => 'Återanslut'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsSv { 'common.mute' => 'Ljud av', 'common.ok' => 'OK', 'common.loading' => 'Laddar...', + 'common.reconnect' => 'Återanslut', 'screens.licenses' => 'Licenser', 'screens.switchProfile' => 'Byt profil', 'screens.subtitleStyling' => 'Undertext-styling', @@ -1679,9 +1681,9 @@ extension on TranslationsSv { 'playlists.errorRemoving' => 'Det gick inte att ta bort från spellista', 'playlists.playlist' => 'Spellista', 'collections.title' => 'Samlingar', - 'collections.collection' => 'Samling', _ => null, } ?? switch (path) { + 'collections.collection' => 'Samling', 'collections.empty' => 'Samlingen är tom', 'collections.unknownLibrarySection' => 'Kan inte ta bort: okänd bibliotekssektion', 'collections.deleteCollection' => 'Ta bort samling', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 5b1e3f6c..d206148d 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -143,6 +143,7 @@ class _TranslationsCommonZh implements TranslationsCommonEn { @override String get mute => '静音'; @override String get ok => '确定'; @override String get loading => '加载中...'; + @override String get reconnect => '重新连接'; } // Path: screens @@ -1211,6 +1212,7 @@ extension on TranslationsZh { 'common.mute' => '静音', 'common.ok' => '确定', 'common.loading' => '加载中...', + 'common.reconnect' => '重新连接', 'screens.licenses' => '许可证', 'screens.switchProfile' => '切换用户', 'screens.subtitleStyling' => '字幕样式', @@ -1679,9 +1681,9 @@ extension on TranslationsZh { 'playlists.errorRemoving' => '从播放列表中移除失败', 'playlists.playlist' => '播放列表', 'collections.title' => '合集', - 'collections.collection' => '合集', _ => null, } ?? switch (path) { + 'collections.collection' => '合集', 'collections.empty' => '合集为空', 'collections.unknownLibrarySection' => '无法删除:未知的媒体库分区', 'collections.deleteCollection' => '删除合集', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index d0590a67..2b4e1922 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -46,7 +46,8 @@ "settings": "Inställningar", "mute": "Ljud av", "ok": "OK", - "loading": "Laddar..." + "loading": "Laddar...", + "reconnect": "Återanslut" }, "screens": { "licenses": "Licenser", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 3dff9b66..42621fc3 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -46,7 +46,8 @@ "settings": "设置", "mute": "静音", "ok": "确定", - "loading": "加载中..." + "loading": "加载中...", + "reconnect": "重新连接" }, "screens": { "licenses": "许可证", diff --git a/lib/main.dart b/lib/main.dart index 27c8be25..8dadf4f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -236,6 +236,9 @@ class _MainAppState extends State with WidgetsBindingObserver { // App came back to foreground - trigger sync check and start new session _offlineWatchSyncService.onAppResumed(); InAppReviewService.instance.startSession(); + // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep + _serverManager.checkServerHealth(); + _serverManager.reconnectOfflineServers(); case AppLifecycleState.paused: case AppLifecycleState.detached: // App went to background or is closing - end session diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index f4f2d7bc..c58330d6 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -2,6 +2,7 @@ import 'dart:io' show Platform, exit; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show KeyUpEvent, SystemNavigator; +import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; @@ -93,6 +94,9 @@ class _MainScreenState extends State with RouteAware, WindowListener OfflineModeProvider? _offlineModeProvider; + /// Whether a reconnection attempt is in progress + bool _isReconnecting = false; + /// Prevents double-pushing the profile selection screen bool _isShowingProfileSelection = false; @@ -539,6 +543,20 @@ class _MainScreenState extends State with RouteAware, WindowListener return newIndex >= 0 ? newIndex : 0; } + void _triggerReconnect() { + if (_isReconnecting) return; + setState(() => _isReconnecting = true); + + final serverManager = context.read().serverManager; + serverManager.checkServerHealth(); + serverManager.reconnectOfflineServers().whenComplete(() { + // Give a moment for status updates to propagate + Future.delayed(const Duration(seconds: 1), () { + if (mounted) setState(() => _isReconnecting = false); + }); + }); + } + void _handleOfflineStatusChanged() { final newOffline = _offlineModeProvider?.isOffline ?? widget.isOfflineMode; @@ -547,6 +565,7 @@ class _MainScreenState extends State with RouteAware, WindowListener final previousTabId = _tabIdForIndex(_isOffline, _currentIndex); final wasOffline = _isOffline; setState(() { + _isReconnecting = false; _isOffline = newOffline; _screens = _buildScreens(_isOffline); _selectedLibraryGlobalKey = _isOffline ? null : _selectedLibraryGlobalKey; @@ -909,6 +928,7 @@ class _MainScreenState extends State with RouteAware, WindowListener isOfflineMode: _isOffline, isSidebarFocused: _isSidebarFocused, alwaysExpanded: alwaysExpanded, + isReconnecting: _isReconnecting, onDestinationSelected: (index) { _selectTab(index); _focusContent(); @@ -918,6 +938,7 @@ class _MainScreenState extends State with RouteAware, WindowListener _focusContent(); }, onNavigateToContent: _focusContent, + onReconnect: _triggerReconnect, ), ), ), @@ -933,10 +954,51 @@ class _MainScreenState extends State with RouteAware, WindowListener return Scaffold( body: IndexedStack(index: _currentIndex, children: _screens), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: _selectTab, - destinations: _buildNavDestinations(_isOffline), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Reconnect bar when offline + if (_isOffline) + Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: InkWell( + onTap: _isReconnecting ? null : _triggerReconnect, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isReconnecting) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Text( + t.common.reconnect, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + ), + NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _selectTab, + destinations: _buildNavDestinations(_isOffline), + ), + ], ), ); } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 67a46017..d766e09c 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -32,6 +32,12 @@ class MultiServerManager { /// Map of serverId to active optimization futures final Map> _activeOptimizations = {}; + /// Cached client identifier for reconnection without async storage lookup + String? _clientIdentifier; + + /// Debounce timers for endpoint-exhaustion-triggered reconnection (per server) + final Map _reconnectDebounce = {}; + /// Get all registered server IDs List get serverIds => _servers.keys.toList(); @@ -103,6 +109,7 @@ class MultiServerManager { await storage.saveServerEndpoint(serverId, newUrl); appLogger.i('Updated endpoint for ${server.name} after failover: $newUrl'); }, + onAllEndpointsExhausted: () => _onServerEndpointsExhausted(serverId), ); // Save the initial endpoint @@ -170,6 +177,7 @@ class MultiServerManager { // Use provided client ID or generate a unique one for this app instance final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString(); + _clientIdentifier = effectiveClientId; // Create connection tasks for all servers final connectionFutures = servers.map((server) async { @@ -234,6 +242,7 @@ class MultiServerManager { Future addServer(PlexServer server, {String? clientIdentifier}) async { final serverId = server.clientIdentifier; final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString(); + _clientIdentifier ??= effectiveClientId; try { appLogger.d('Adding server: ${server.name}'); @@ -344,28 +353,30 @@ class MultiServerManager { appLogger.i('Stopped network monitoring'); } - /// Re-optimize all connected servers + /// Re-optimize all connected servers and attempt reconnection for offline ones void _reoptimizeAllServers({required String reason}) { for (final entry in _servers.entries) { final serverId = entry.key; final server = entry.value; - // Skip if server is offline - if (!isServerOnline(serverId)) { - continue; - } - - // Skip if optimization already running for this server + // Skip if optimization/reconnection already running for this server if (_activeOptimizations.containsKey(serverId)) { appLogger.d('Optimization already running for ${server.name}, skipping', error: {'reason': reason}); continue; } - // Run optimization - _activeOptimizations[serverId] = _reoptimizeServer(serverId: serverId, server: server, reason: reason) - .whenComplete(() { - _activeOptimizations.remove(serverId); - }); + if (!isServerOnline(serverId)) { + // Attempt reconnection for offline servers + _activeOptimizations[serverId] = _reconnectServer(serverId, server).whenComplete(() { + _activeOptimizations.remove(serverId); + }); + } else { + // Re-optimize online servers + _activeOptimizations[serverId] = _reoptimizeServer(serverId: serverId, server: server, reason: reason) + .whenComplete(() { + _activeOptimizations.remove(serverId); + }); + } } } @@ -404,10 +415,86 @@ class MultiServerManager { } } + /// Attempt full reconnection for a single offline server + Future _reconnectServer(String serverId, PlexServer server) async { + final clientId = _clientIdentifier; + if (clientId == null) { + appLogger.w('Cannot reconnect ${server.name}: no client identifier cached'); + return; + } + + try { + appLogger.d('Attempting reconnection for ${server.name}'); + final client = await _createClientForServer(server: server, clientIdentifier: clientId); + + _clients[serverId] = client; + updateServerStatus(serverId, true); + appLogger.i('Successfully reconnected to ${server.name}'); + } catch (e) { + appLogger.d('Reconnection failed for ${server.name}: $e'); + // Leave status as offline — will retry on next trigger + } + } + + /// Attempt reconnection for all offline servers + Future reconnectOfflineServers() async { + final offline = offlineServerIds; + if (offline.isEmpty) return; + + appLogger.d('Attempting reconnection for ${offline.length} offline servers'); + + final futures = offline.map((serverId) { + final server = _servers[serverId]; + if (server == null) return Future.value(); + + // Skip if already running + if (_activeOptimizations.containsKey(serverId)) return Future.value(); + + final future = _reconnectServer(serverId, server) + .timeout(const Duration(seconds: 15), onTimeout: () { + appLogger.d('Reconnection timed out for $serverId'); + }) + .whenComplete(() => _activeOptimizations.remove(serverId)); + + _activeOptimizations[serverId] = future; + return future; + }); + + await Future.wait(futures); + } + + /// Called when all failover endpoints are exhausted for a server. + /// Debounced per-server to prevent cascading reconnections from parallel failures. + void _onServerEndpointsExhausted(String serverId) { + // Cancel any existing debounce timer for this server + _reconnectDebounce[serverId]?.cancel(); + + _reconnectDebounce[serverId] = Timer(const Duration(seconds: 5), () { + _reconnectDebounce.remove(serverId); + + final server = _servers[serverId]; + if (server == null) return; + + appLogger.i('All endpoints exhausted for $serverId, triggering reconnection'); + updateServerStatus(serverId, false); + + // Guard with _activeOptimizations to prevent duplicate reconnections + if (_activeOptimizations.containsKey(serverId)) return; + + _activeOptimizations[serverId] = _reconnectServer(serverId, server).whenComplete(() { + _activeOptimizations.remove(serverId); + }); + }); + } + /// Disconnect all servers void disconnectAll() { appLogger.i('Disconnecting all servers'); stopNetworkMonitoring(); + for (final timer in _reconnectDebounce.values) { + timer.cancel(); + } + _reconnectDebounce.clear(); _clients.clear(); _servers.clear(); _serverStatus.clear(); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3dfebf45..cac47cfd 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:ui' show VoidCallback; import 'package:dio/dio.dart'; @@ -46,6 +47,7 @@ class PlexClient { late final Dio _dio; final EndpointFailoverManager? _endpointManager; final Future Function(String newBaseUrl)? _onEndpointChanged; + final VoidCallback? _onAllEndpointsExhausted; /// Server identifier - all PlexMetadata items created by this client are tagged with this final String serverId; @@ -78,10 +80,12 @@ class PlexClient { this.serverName, List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, + VoidCallback? onAllEndpointsExhausted, }) : _endpointManager = (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) ? EndpointFailoverManager(prioritizedEndpoints) : null, - _onEndpointChanged = onEndpointChanged { + _onEndpointChanged = onEndpointChanged, + _onAllEndpointsExhausted = onAllEndpointsExhausted { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); @@ -109,6 +113,7 @@ class PlexClient { dio: _dio, endpointManager: _endpointManager, onEndpointSwitch: _handleEndpointSwitch, + onAllEndpointsExhausted: _onAllEndpointsExhausted, ), ); } diff --git a/lib/utils/endpoint_failover_interceptor.dart b/lib/utils/endpoint_failover_interceptor.dart index 7069d1e2..27ae2123 100644 --- a/lib/utils/endpoint_failover_interceptor.dart +++ b/lib/utils/endpoint_failover_interceptor.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show VoidCallback; + import 'package:dio/dio.dart'; import '../utils/app_logger.dart'; @@ -77,12 +79,14 @@ class EndpointFailoverInterceptor extends Interceptor { required Dio dio, required this.endpointManager, required Future Function(String newBaseUrl) onEndpointSwitch, + this.onAllEndpointsExhausted, }) : _dio = dio, _onEndpointSwitch = onEndpointSwitch; final Dio _dio; final EndpointFailoverManager endpointManager; final Future Function(String newBaseUrl) _onEndpointSwitch; + final VoidCallback? onAllEndpointsExhausted; bool _isSwitching = false; @override @@ -116,6 +120,7 @@ class EndpointFailoverInterceptor extends Interceptor { // All endpoints exhausted — reset to first so the next failure cycle // starts from the preferred endpoint (handles transient network outages). endpointManager.resetToFirst(); + onAllEndpointsExhausted?.call(); handler.next(err); return; } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 457c1a88..ce3d8542 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -130,12 +130,16 @@ class SideNavigationRail extends StatefulWidget { final bool isOfflineMode; final bool isSidebarFocused; final bool alwaysExpanded; + final bool isReconnecting; final ValueChanged onDestinationSelected; final ValueChanged onLibrarySelected; /// Called when RIGHT arrow is pressed to navigate to content without selecting. final VoidCallback? onNavigateToContent; + /// Called when the user taps the reconnect button in offline mode. + final VoidCallback? onReconnect; + const SideNavigationRail({ super.key, required this.selectedIndex, @@ -143,9 +147,11 @@ class SideNavigationRail extends StatefulWidget { this.isOfflineMode = false, this.isSidebarFocused = false, this.alwaysExpanded = false, + this.isReconnecting = false, required this.onDestinationSelected, required this.onLibrarySelected, this.onNavigateToContent, + this.onReconnect, }); @override @@ -168,6 +174,7 @@ class SideNavigationRailState extends State { static const _kSearch = 'search'; static const _kDownloads = 'downloads'; static const _kSettings = 'settings'; + static const _kReconnect = 'reconnect'; // Unified focus state tracker for all nav items (main + libraries) late final FocusMemoryTracker _focusTracker; @@ -237,7 +244,7 @@ class SideNavigationRailState extends State { /// Build the set of valid focus keys (main nav + current libraries) Set _buildValidFocusKeys(List libraries) { - return {_kHome, _kLibraries, _kSearch, _kDownloads, _kSettings, ...libraries.map((lib) => lib.globalKey)}; + return {_kHome, _kLibraries, _kSearch, _kDownloads, _kSettings, _kReconnect, ...libraries.map((lib) => lib.globalKey)}; } /// Reload libraries (called when servers change or profile switches) @@ -317,7 +324,13 @@ class SideNavigationRailState extends State { padding: const EdgeInsets.symmetric(horizontal: 12), clipBehavior: Clip.hardEdge, children: [ - // In offline mode, only show Downloads and Settings + // Reconnect button when offline + if (widget.isOfflineMode && widget.onReconnect != null) ...[ + _buildReconnectItem(isCollapsed: isCollapsed), + const SizedBox(height: 8), + ], + + // In online mode, show full navigation if (!widget.isOfflineMode) ...[ // Home _buildNavItem( @@ -437,6 +450,33 @@ class SideNavigationRailState extends State { ); } + Widget _buildReconnectItem({required bool isCollapsed}) { + final t = tokens(context); + final isFocused = _focusTracker.isFocused(_kReconnect); + + return NavigationRailItem( + icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded, + label: widget.isReconnecting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: t.text), + ) + : Text( + Translations.of(context).common.reconnect, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: t.textMuted), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + isSelected: false, + isFocused: isFocused, + isCollapsed: isCollapsed, + onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(), + focusNode: _focusTracker.get(_kReconnect), + onNavigateRight: widget.onNavigateToContent, + ); + } + Widget _buildLibrariesSection(List visibleLibraries, dynamic t, {bool isCollapsed = false}) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading;