feat: connection recovery on mobile resume and endpoint exhaustion

This commit is contained in:
edde746
2026-02-14 22:11:00 +01:00
parent 0d72c6c1a4
commit 4fc98426ff
25 changed files with 270 additions and 39 deletions
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Einstellungen",
"mute": "Stumm",
"ok": "OK",
"loading": "Laden..."
"loading": "Laden...",
"reconnect": "Erneut verbinden"
},
"screens": {
"licenses": "Lizenzen",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Settings",
"mute": "Mute",
"ok": "OK",
"loading": "Loading..."
"loading": "Loading...",
"reconnect": "Reconnect"
},
"screens": {
"licenses": "Licenses",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Ajustes",
"mute": "Silencio",
"ok": "OK",
"loading": "Cargando..."
"loading": "Cargando...",
"reconnect": "Reconectar"
},
"screens": {
"licenses": "Licencias",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Paramètres",
"mute": "Muet",
"ok": "OK",
"loading": "Chargement..."
"loading": "Chargement...",
"reconnect": "Reconnecter"
},
"screens": {
"licenses": "Licenses",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Impostazioni",
"mute": "Muto",
"ok": "OK",
"loading": "Caricamento..."
"loading": "Caricamento...",
"reconnect": "Riconnetti"
},
"screens": {
"licenses": "Licenze",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "설정",
"mute": "음소거",
"ok": "확인",
"loading": "로딩 중..."
"loading": "로딩 중...",
"reconnect": "다시 연결"
},
"screens": {
"licenses": "라이선스",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Instellingen",
"mute": "Dempen",
"ok": "OK",
"loading": "Laden..."
"loading": "Laden...",
"reconnect": "Opnieuw verbinden"
},
"screens": {
"licenses": "Licenties",
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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',
+5 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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' => '함께 보기',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -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' => '删除合集',
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "Inställningar",
"mute": "Ljud av",
"ok": "OK",
"loading": "Laddar..."
"loading": "Laddar...",
"reconnect": "Återanslut"
},
"screens": {
"licenses": "Licenser",
+2 -1
View File
@@ -46,7 +46,8 @@
"settings": "设置",
"mute": "静音",
"ok": "确定",
"loading": "加载中..."
"loading": "加载中...",
"reconnect": "重新连接"
},
"screens": {
"licenses": "许可证",
+3
View File
@@ -236,6 +236,9 @@ class _MainAppState extends State<MainApp> 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
+66 -4
View File
@@ -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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
return newIndex >= 0 ? newIndex : 0;
}
void _triggerReconnect() {
if (_isReconnecting) return;
setState(() => _isReconnecting = true);
final serverManager = context.read<MultiServerProvider>().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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
isOfflineMode: _isOffline,
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onDestinationSelected: (index) {
_selectTab(index);
_focusContent();
@@ -918,6 +938,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
_focusContent();
},
onNavigateToContent: _focusContent,
onReconnect: _triggerReconnect,
),
),
),
@@ -933,10 +954,51 @@ class _MainScreenState extends State<MainScreen> 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),
),
],
),
);
}
+99 -12
View File
@@ -32,6 +32,12 @@ class MultiServerManager {
/// Map of serverId to active optimization futures
final Map<String, Future<void>> _activeOptimizations = {};
/// Cached client identifier for reconnection without async storage lookup
String? _clientIdentifier;
/// Debounce timers for endpoint-exhaustion-triggered reconnection (per server)
final Map<String, Timer> _reconnectDebounce = {};
/// Get all registered server IDs
List<String> 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<bool> 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<void> _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<void> 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<void>.value();
// Skip if already running
if (_activeOptimizations.containsKey(serverId)) return Future<void>.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();
+6 -1
View File
@@ -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<void> 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<String>? prioritizedEndpoints,
Future<void> 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,
),
);
}
@@ -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<void> Function(String newBaseUrl) onEndpointSwitch,
this.onAllEndpointsExhausted,
}) : _dio = dio,
_onEndpointSwitch = onEndpointSwitch;
final Dio _dio;
final EndpointFailoverManager endpointManager;
final Future<void> 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;
}
+42 -2
View File
@@ -130,12 +130,16 @@ class SideNavigationRail extends StatefulWidget {
final bool isOfflineMode;
final bool isSidebarFocused;
final bool alwaysExpanded;
final bool isReconnecting;
final ValueChanged<int> onDestinationSelected;
final ValueChanged<String> 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<SideNavigationRail> {
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<SideNavigationRail> {
/// Build the set of valid focus keys (main nav + current libraries)
Set<String> _buildValidFocusKeys(List<PlexLibrary> 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<SideNavigationRail> {
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<SideNavigationRail> {
);
}
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<PlexLibrary> visibleLibraries, dynamic t, {bool isCollapsed = false}) {
final librariesProvider = context.watch<LibrariesProvider>();
final isLoading = librariesProvider.isLoading;