feat: connection recovery on mobile resume and endpoint exhaustion
This commit is contained in:
@@ -46,7 +46,8 @@
|
||||
"settings": "Einstellungen",
|
||||
"mute": "Stumm",
|
||||
"ok": "OK",
|
||||
"loading": "Laden..."
|
||||
"loading": "Laden...",
|
||||
"reconnect": "Erneut verbinden"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Lizenzen",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Settings",
|
||||
"mute": "Mute",
|
||||
"ok": "OK",
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Ajustes",
|
||||
"mute": "Silencio",
|
||||
"ok": "OK",
|
||||
"loading": "Cargando..."
|
||||
"loading": "Cargando...",
|
||||
"reconnect": "Reconectar"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licencias",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Paramètres",
|
||||
"mute": "Muet",
|
||||
"ok": "OK",
|
||||
"loading": "Chargement..."
|
||||
"loading": "Chargement...",
|
||||
"reconnect": "Reconnecter"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenses",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Impostazioni",
|
||||
"mute": "Muto",
|
||||
"ok": "OK",
|
||||
"loading": "Caricamento..."
|
||||
"loading": "Caricamento...",
|
||||
"reconnect": "Riconnetti"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenze",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "설정",
|
||||
"mute": "음소거",
|
||||
"ok": "확인",
|
||||
"loading": "로딩 중..."
|
||||
"loading": "로딩 중...",
|
||||
"reconnect": "다시 연결"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "라이선스",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Instellingen",
|
||||
"mute": "Dempen",
|
||||
"ok": "OK",
|
||||
"loading": "Laden..."
|
||||
"loading": "Laden...",
|
||||
"reconnect": "Opnieuw verbinden"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenties",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '함께 보기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '删除合集',
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "Inställningar",
|
||||
"mute": "Ljud av",
|
||||
"ok": "OK",
|
||||
"loading": "Laddar..."
|
||||
"loading": "Laddar...",
|
||||
"reconnect": "Återanslut"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "Licenser",
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"settings": "设置",
|
||||
"mute": "静音",
|
||||
"ok": "确定",
|
||||
"loading": "加载中..."
|
||||
"loading": "加载中...",
|
||||
"reconnect": "重新连接"
|
||||
},
|
||||
"screens": {
|
||||
"licenses": "许可证",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user