From 6e8a1a373e8f8cfbeaef0fa63590396e9617d227 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 21 Nov 2025 23:59:39 +0100 Subject: [PATCH] feat: remove server switcher --- lib/client/plex_client.dart | 16 +- lib/i18n/strings.g.dart | 576 ++++++++++-------- lib/i18n/strings.i18n.json | 7 +- lib/i18n/strings_de.i18n.json | 5 + lib/i18n/strings_it.i18n.json | 5 + lib/i18n/strings_nl.i18n.json | 5 + lib/i18n/strings_sv.i18n.json | 5 + lib/i18n/strings_zh.i18n.json | 5 + lib/main.dart | 173 +++--- lib/models/plex_hub.dart | 6 + lib/models/plex_library.dart | 11 + lib/models/plex_metadata.dart | 15 + lib/models/plex_playlist.dart | 56 ++ lib/providers/multi_server_provider.dart | 97 +++ lib/providers/playback_state_provider.dart | 22 +- lib/providers/server_state_provider.dart | 76 +++ lib/screens/auth_screen.dart | 104 +++- .../base_media_list_detail_screen.dart | 45 +- lib/screens/collection_detail_screen.dart | 37 +- lib/screens/discover_screen.dart | 186 ++---- lib/screens/hub_detail_screen.dart | 47 +- lib/screens/libraries_screen.dart | 396 ++++++++---- .../library_tabs/library_browse_tab.dart | 63 +- .../library_tabs/library_collections_tab.dart | 38 +- .../library_tabs/library_playlists_tab.dart | 38 +- .../library_tabs/library_recommended_tab.dart | 53 +- lib/screens/media_detail_screen.dart | 119 ++-- lib/screens/playlist_detail_screen.dart | 37 +- lib/screens/search_screen.dart | 16 +- lib/screens/season_detail_screen.dart | 64 +- lib/screens/server_selection_screen.dart | 329 ---------- lib/screens/video_player_screen.dart | 60 +- lib/services/data_aggregation_service.dart | 364 +++++++++++ lib/services/multi_server_manager.dart | 273 +++++++++ lib/services/server_registry.dart | 240 ++++++++ lib/services/storage_service.dart | 48 +- .../collection_playlist_play_helper.dart | 52 +- lib/utils/shuffle_play_helper.dart | 53 +- lib/widgets/folder_tree_view.dart | 57 +- lib/widgets/media_card.dart | 35 +- lib/widgets/media_context_menu.dart | 50 +- lib/widgets/playlist_item_card.dart | 28 +- lib/widgets/server_badge.dart | 122 ++++ lib/widgets/server_list_tile.dart | 103 ---- .../video_controls/sheets/chapter_sheet.dart | 35 +- .../video_controls/video_controls.dart | 31 +- 46 files changed, 2998 insertions(+), 1205 deletions(-) create mode 100644 lib/providers/multi_server_provider.dart create mode 100644 lib/providers/server_state_provider.dart delete mode 100644 lib/screens/server_selection_screen.dart create mode 100644 lib/services/data_aggregation_service.dart create mode 100644 lib/services/multi_server_manager.dart create mode 100644 lib/services/server_registry.dart create mode 100644 lib/widgets/server_badge.dart delete mode 100644 lib/widgets/server_list_tile.dart diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 1aa617c8..45552c39 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -1122,6 +1122,10 @@ class PlexClient { return null; } + // Extract serverId/serverName from currentEpisode to propagate + final serverId = currentEpisode.serverId; + final serverName = currentEpisode.serverName; + try { // Get all episodes in the current season final episodes = await getChildren(parentKey); @@ -1137,7 +1141,10 @@ class PlexClient { // Check if target episode is within current season if (targetIndex >= 0 && targetIndex < episodes.length) { - return episodes[targetIndex]; + return episodes[targetIndex].copyWith( + serverId: serverId, + serverName: serverName, + ); } // Need to move to adjacent season @@ -1165,9 +1172,12 @@ class PlexClient { if (targetSeasonEpisodes.isNotEmpty) { // Return first episode for next season, last for previous - return direction > 0 + return (direction > 0 ? targetSeasonEpisodes.first - : targetSeasonEpisodes.last; + : targetSeasonEpisodes.last).copyWith( + serverId: serverId, + serverName: serverName, + ); } } } diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 72175a50..46f40a82 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 6 -/// Strings: 2634 (439 per locale) +/// Strings: 2662 (443 per locale) /// -/// Built on 2025-11-18 at 18:40 UTC +/// Built on 2025-11-21 at 22:35 UTC // coverage:ignore-file // ignore_for_file: type=lint @@ -256,7 +256,6 @@ class _StringsScreensEn { // Translations String get licenses => 'Licenses'; - String get selectServer => 'Select Server'; String get switchProfile => 'Switch Profile'; String get subtitleStyling => 'Subtitle Styling'; String get search => 'Search'; @@ -624,7 +623,6 @@ class _StringsDiscoverEn { // Translations String get title => 'Discover'; String get switchProfile => 'Switch Profile'; - String get switchServer => 'Switch Server'; String get logout => 'Logout'; String get noContentAvailable => 'No content available'; String get addMediaToLibraries => 'Add some media to your libraries'; @@ -739,6 +737,11 @@ class _StringsServerSelectionEn { // Translations String get connectingToServer => 'Connecting to server...'; + String get connectingToAllServers => 'Connecting to all servers...'; + String get connectToAllServers => 'Connect to All Servers'; + String get allServerConnectionsFailed => 'Failed to connect to any servers. Please check your network and try again.'; + String foundServers({required Object count}) => 'Found ${count} server(s)'; + String get orSelectIndividually => 'Or select a server individually:'; String get serverDebugCopied => 'Server debug data copied to clipboard'; String get copyDebugData => 'Copy Debug Data'; String get noServersFound => 'No servers found'; @@ -1524,6 +1527,11 @@ class _StringsServerSelectionDe implements _StringsServerSelectionEn { // Translations @override String get connectingToServer => 'Verbinde mit Server...'; + @override String get connectingToAllServers => 'Verbinde mit allen Servern...'; + @override String get connectToAllServers => 'Mit allen Servern verbinden'; + @override String get allServerConnectionsFailed => 'Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.'; + @override String foundServers({required Object count}) => '${count} Server gefunden'; + @override String get orSelectIndividually => 'Oder einzelnen Server auswählen:'; @override String get serverDebugCopied => 'Server-Debugdaten kopiert'; @override String get copyDebugData => 'Debugdaten kopieren'; @override String get noServersFound => 'Keine Server gefunden'; @@ -1830,7 +1838,7 @@ class _StringsScreensIt implements _StringsScreensEn { @override String get switchProfile => 'Cambia profilo'; @override String get subtitleStyling => 'Stile sottotitoli'; @override String get search => 'Cerca'; - @override String get logs => 'Logs'; + @override String get logs => 'Registro'; } // Path: update @@ -2023,14 +2031,14 @@ class _StringsAccessibilityIt implements _StringsAccessibilityEn { @override final _StringsIt _root; // ignore: unused_field // Translations - @override String mediaCardMovie({required Object title}) => '${title}, movie'; - @override String mediaCardShow({required Object title}) => '${title}, TV show'; + @override String mediaCardMovie({required Object title}) => '${title}, film'; + @override String mediaCardShow({required Object title}) => '${title}, serie TV'; @override String mediaCardEpisode({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; @override String mediaCardSeason({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - @override String get mediaCardWatched => 'watched'; - @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} percent watched'; - @override String get mediaCardUnwatched => 'unwatched'; - @override String get tapToPlay => 'Tap to play'; + @override String get mediaCardWatched => 'visto'; + @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} percento visto'; + @override String get mediaCardUnwatched => 'non visto'; + @override String get tapToPlay => 'Tocca per riprodurre'; } // Path: tooltips @@ -2059,7 +2067,7 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String minusTime({required Object amount, required Object unit}) => '-${amount}${unit}'; @override String playsLater({required Object label}) => '${label} riprodotto dopo'; @override String playsEarlier({required Object label}) => '${label} riprodotto prima'; - @override String get noOffset => 'No offset'; + @override String get noOffset => 'Nessun offset'; @override String get letterbox => 'Letterbox'; @override String get fillScreen => 'Riempi schermo'; @override String get stretch => 'Allunga'; @@ -2069,29 +2077,29 @@ class _StringsVideoControlsIt implements _StringsVideoControlsEn { @override String get timerActive => 'Timer attivo'; @override String playbackWillPauseIn({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; @override String get sleepTimerCompleted => 'Timer di spegnimento completato - riproduzione in pausa'; - @override String get playButton => 'Play'; - @override String get pauseButton => 'Pause'; - @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; - @override String seekForwardButton({required Object seconds}) => 'Seek forward ${seconds} seconds'; - @override String get previousButton => 'Previous episode'; - @override String get nextButton => 'Next episode'; - @override String get previousChapterButton => 'Previous chapter'; - @override String get nextChapterButton => 'Next chapter'; + @override String get playButton => 'Riproduci'; + @override String get pauseButton => 'Pausa'; + @override String seekBackwardButton({required Object seconds}) => 'Riavvolgi di ${seconds} secondi'; + @override String seekForwardButton({required Object seconds}) => 'Avanza di ${seconds} secondi'; + @override String get previousButton => 'Episodio precedente'; + @override String get nextButton => 'Episodio successivo'; + @override String get previousChapterButton => 'Capitolo precedente'; + @override String get nextChapterButton => 'Capitolo successivo'; @override String get volumeButton => 'Volume'; - @override String get muteButton => 'Mute'; - @override String get unmuteButton => 'Unmute'; - @override String get settingsButton => 'Video settings'; - @override String get audioTrackButton => 'Audio tracks'; - @override String get subtitlesButton => 'Subtitles'; - @override String get chaptersButton => 'Chapters'; - @override String get versionsButton => 'Video versions'; - @override String get aspectRatioButton => 'Aspect ratio'; - @override String get fullscreenButton => 'Enter fullscreen'; - @override String get exitFullscreenButton => 'Exit fullscreen'; - @override String get rotationLockButton => 'Rotation lock'; - @override String get timelineSlider => 'Video timeline'; - @override String get volumeSlider => 'Volume level'; - @override String get backButton => 'Back'; + @override String get muteButton => 'Silenzia'; + @override String get unmuteButton => 'Riattiva audio'; + @override String get settingsButton => 'Impostazioni video'; + @override String get audioTrackButton => 'Tracce audio'; + @override String get subtitlesButton => 'Sottotitoli'; + @override String get chaptersButton => 'Capitoli'; + @override String get versionsButton => 'Versioni video'; + @override String get aspectRatioButton => 'Proporzioni'; + @override String get fullscreenButton => 'Attiva schermo intero'; + @override String get exitFullscreenButton => 'Esci da schermo intero'; + @override String get rotationLockButton => 'Blocco rotazione'; + @override String get timelineSlider => 'Timeline video'; + @override String get volumeSlider => 'Livello volume'; + @override String get backButton => 'Indietro'; } // Path: userStatus @@ -2192,7 +2200,7 @@ class _StringsDiscoverIt implements _StringsDiscoverEn { @override final _StringsIt _root; // ignore: unused_field // Translations - @override String get title => 'Discover'; + @override String get title => 'Esplora'; @override String get switchProfile => 'Cambia profilo'; @override String get switchServer => 'Cambia server'; @override String get logout => 'Disconnetti'; @@ -2206,7 +2214,7 @@ class _StringsDiscoverIt implements _StringsDiscoverEn { @override String resumeEpisode({required Object season, required Object episode}) => 'Riprendi S${season}, E${episode}'; @override String get pause => 'Pausa'; @override String get overview => 'Panoramica'; - @override String get cast => 'Cast'; + @override String get cast => 'Attori'; @override String get seasons => 'Stagioni'; @override String get studio => 'Studio'; @override String get rating => 'Classificazione'; @@ -2309,6 +2317,11 @@ class _StringsServerSelectionIt implements _StringsServerSelectionEn { // Translations @override String get connectingToServer => 'Connessione al server...'; + @override String get connectingToAllServers => 'Connessione a tutti i server...'; + @override String get connectToAllServers => 'Connetti a tutti i server'; + @override String get allServerConnectionsFailed => 'Impossibile connettersi a nessun server. Controlla la tua rete e riprova.'; + @override String foundServers({required Object count}) => 'Trovati ${count} server'; + @override String get orSelectIndividually => 'Oppure seleziona un server singolarmente:'; @override String get serverDebugCopied => 'Dati di debug del server copiati negli appunti'; @override String get copyDebugData => 'Copia dati di debug'; @override String get noServersFound => 'Nessun server trovato'; @@ -2343,7 +2356,7 @@ class _StringsLogsIt implements _StringsLogsEn { @override final _StringsIt _root; // ignore: unused_field // Translations - @override String get title => 'Log'; + @override String get title => 'Registro'; @override String get clearLogs => 'Cancella log'; @override String get copyLogs => 'Copia log'; @override String get exportLogs => 'Esporta log'; @@ -2563,7 +2576,7 @@ class _StringsAuthNl implements _StringsAuthEn { @override String get authenticate => 'Authenticeren'; @override String get retry => 'Opnieuw proberen'; @override String get debugEnterToken => 'Debug: Voer Plex Token in'; - @override String get plexTokenLabel => 'Plex Auth Token'; + @override String get plexTokenLabel => 'Plex Authenticatietoken'; @override String get plexTokenHint => 'Voer je Plex.tv token in'; @override String get authenticationTimeout => 'Authenticatie verlopen. Probeer opnieuw.'; @override String get scanQRCodeInstruction => 'Scan deze QR-code met een apparaat dat is ingelogd op Plex om te authenticeren.'; @@ -2599,7 +2612,7 @@ class _StringsCommonNl implements _StringsCommonEn { @override String get no => 'Nee'; @override String get server => 'Server'; @override String get delete => 'Verwijderen'; - @override String get shuffle => 'Shuffle'; + @override String get shuffle => 'Willekeurig'; @override String get addTo => 'Toevoegen aan...'; } @@ -2615,7 +2628,7 @@ class _StringsScreensNl implements _StringsScreensEn { @override String get switchProfile => 'Wissel van profiel'; @override String get subtitleStyling => 'Ondertitel opmaak'; @override String get search => 'Zoeken'; - @override String get logs => 'Logs'; + @override String get logs => 'Logbestanden'; } // Path: update @@ -2808,14 +2821,14 @@ class _StringsAccessibilityNl implements _StringsAccessibilityEn { @override final _StringsNl _root; // ignore: unused_field // Translations - @override String mediaCardMovie({required Object title}) => '${title}, movie'; - @override String mediaCardShow({required Object title}) => '${title}, TV show'; + @override String mediaCardMovie({required Object title}) => '${title}, film'; + @override String mediaCardShow({required Object title}) => '${title}, TV-serie'; @override String mediaCardEpisode({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; @override String mediaCardSeason({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - @override String get mediaCardWatched => 'watched'; - @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} percent watched'; - @override String get mediaCardUnwatched => 'unwatched'; - @override String get tapToPlay => 'Tap to play'; + @override String get mediaCardWatched => 'bekeken'; + @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} procent bekeken'; + @override String get mediaCardUnwatched => 'niet bekeken'; + @override String get tapToPlay => 'Tik om af te spelen'; } // Path: tooltips @@ -2854,29 +2867,29 @@ class _StringsVideoControlsNl implements _StringsVideoControlsEn { @override String get timerActive => 'Timer actief'; @override String playbackWillPauseIn({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; @override String get sleepTimerCompleted => 'Slaaptimer voltooid - afspelen gepauzeerd'; - @override String get playButton => 'Play'; - @override String get pauseButton => 'Pause'; - @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; - @override String seekForwardButton({required Object seconds}) => 'Seek forward ${seconds} seconds'; - @override String get previousButton => 'Previous episode'; - @override String get nextButton => 'Next episode'; - @override String get previousChapterButton => 'Previous chapter'; - @override String get nextChapterButton => 'Next chapter'; + @override String get playButton => 'Afspelen'; + @override String get pauseButton => 'Pauzeren'; + @override String seekBackwardButton({required Object seconds}) => 'Terugspoelen ${seconds} seconden'; + @override String seekForwardButton({required Object seconds}) => 'Vooruitspoelen ${seconds} seconden'; + @override String get previousButton => 'Vorige aflevering'; + @override String get nextButton => 'Volgende aflevering'; + @override String get previousChapterButton => 'Vorig hoofdstuk'; + @override String get nextChapterButton => 'Volgend hoofdstuk'; @override String get volumeButton => 'Volume'; - @override String get muteButton => 'Mute'; - @override String get unmuteButton => 'Unmute'; - @override String get settingsButton => 'Video settings'; - @override String get audioTrackButton => 'Audio tracks'; - @override String get subtitlesButton => 'Subtitles'; - @override String get chaptersButton => 'Chapters'; - @override String get versionsButton => 'Video versions'; - @override String get aspectRatioButton => 'Aspect ratio'; - @override String get fullscreenButton => 'Enter fullscreen'; - @override String get exitFullscreenButton => 'Exit fullscreen'; - @override String get rotationLockButton => 'Rotation lock'; - @override String get timelineSlider => 'Video timeline'; - @override String get volumeSlider => 'Volume level'; - @override String get backButton => 'Back'; + @override String get muteButton => 'Dempen'; + @override String get unmuteButton => 'Dempen opheffen'; + @override String get settingsButton => 'Video-instellingen'; + @override String get audioTrackButton => 'Audiosporen'; + @override String get subtitlesButton => 'Ondertitels'; + @override String get chaptersButton => 'Hoofdstukken'; + @override String get versionsButton => 'Videoversies'; + @override String get aspectRatioButton => 'Beeldverhouding'; + @override String get fullscreenButton => 'Volledig scherm activeren'; + @override String get exitFullscreenButton => 'Volledig scherm verlaten'; + @override String get rotationLockButton => 'Rotatievergrendeling'; + @override String get timelineSlider => 'Videotijdlijn'; + @override String get volumeSlider => 'Volumeniveau'; + @override String get backButton => 'Terug'; } // Path: userStatus @@ -2991,7 +3004,7 @@ class _StringsDiscoverNl implements _StringsDiscoverEn { @override String resumeEpisode({required Object season, required Object episode}) => 'Hervat S${season}, E${episode}'; @override String get pause => 'Pauzeren'; @override String get overview => 'Overzicht'; - @override String get cast => 'Cast'; + @override String get cast => 'Acteurs'; @override String get seasons => 'Seizoenen'; @override String get studio => 'Studio'; @override String get rating => 'Leeftijd'; @@ -3094,6 +3107,11 @@ class _StringsServerSelectionNl implements _StringsServerSelectionEn { // Translations @override String get connectingToServer => 'Verbinden met server...'; + @override String get connectingToAllServers => 'Verbinden met alle servers...'; + @override String get connectToAllServers => 'Verbind met alle servers'; + @override String get allServerConnectionsFailed => 'Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.'; + @override String foundServers({required Object count}) => '${count} server(s) gevonden'; + @override String get orSelectIndividually => 'Of selecteer een server individueel:'; @override String get serverDebugCopied => 'Server debug gegevens gekopieerd naar klembord'; @override String get copyDebugData => 'Kopieer debug gegevens'; @override String get noServersFound => 'Geen servers gevonden'; @@ -3128,13 +3146,13 @@ class _StringsLogsNl implements _StringsLogsEn { @override final _StringsNl _root; // ignore: unused_field // Translations - @override String get title => 'Logs'; + @override String get title => 'Logbestanden'; @override String get clearLogs => 'Wis logs'; @override String get copyLogs => 'Kopieer logs'; @override String get exportLogs => 'Exporteer logs'; @override String get noLogsToShow => 'Geen logs om te tonen'; @override String get error => 'Fout:'; - @override String get stackTrace => 'Stack Trace:'; + @override String get stackTrace => 'Stacktracering:'; } // Path: licenses @@ -3157,7 +3175,7 @@ class _StringsNavigationNl implements _StringsNavigationEn { @override final _StringsNl _root; // ignore: unused_field // Translations - @override String get home => 'Home'; + @override String get home => 'Thuis'; @override String get search => 'Zoeken'; @override String get libraries => 'Bibliotheken'; @override String get settings => 'Instellingen'; @@ -3183,7 +3201,7 @@ class _StringsPlaylistsNl implements _StringsPlaylistsEn { @override String get removeItem => 'Verwijderen uit afspeellijst'; @override String get clearPlaylist => 'Afspeellijst wissen'; @override String get playAll => 'Alles afspelen'; - @override String get shuffle => 'Shuffle'; + @override String get shuffle => 'Willekeurig'; @override String get smartPlaylist => 'Slimme afspeellijst'; @override String get regularPlaylist => 'Normale afspeellijst'; @override String itemCount({required Object count}) => '${count} items'; @@ -3593,14 +3611,14 @@ class _StringsAccessibilitySv implements _StringsAccessibilityEn { @override final _StringsSv _root; // ignore: unused_field // Translations - @override String mediaCardMovie({required Object title}) => '${title}, movie'; - @override String mediaCardShow({required Object title}) => '${title}, TV show'; + @override String mediaCardMovie({required Object title}) => '${title}, film'; + @override String mediaCardShow({required Object title}) => '${title}, TV-serie'; @override String mediaCardEpisode({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; @override String mediaCardSeason({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - @override String get mediaCardWatched => 'watched'; - @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} percent watched'; - @override String get mediaCardUnwatched => 'unwatched'; - @override String get tapToPlay => 'Tap to play'; + @override String get mediaCardWatched => 'sedd'; + @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} procent sedd'; + @override String get mediaCardUnwatched => 'osedd'; + @override String get tapToPlay => 'Tryck för att spela'; } // Path: tooltips @@ -3639,29 +3657,29 @@ class _StringsVideoControlsSv implements _StringsVideoControlsEn { @override String get timerActive => 'Timer aktiv'; @override String playbackWillPauseIn({required Object duration}) => 'Uppspelningen pausas om ${duration}'; @override String get sleepTimerCompleted => 'Sovtimer slutförd - uppspelning pausad'; - @override String get playButton => 'Play'; - @override String get pauseButton => 'Pause'; - @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; - @override String seekForwardButton({required Object seconds}) => 'Seek forward ${seconds} seconds'; - @override String get previousButton => 'Previous episode'; - @override String get nextButton => 'Next episode'; - @override String get previousChapterButton => 'Previous chapter'; - @override String get nextChapterButton => 'Next chapter'; - @override String get volumeButton => 'Volume'; - @override String get muteButton => 'Mute'; - @override String get unmuteButton => 'Unmute'; - @override String get settingsButton => 'Video settings'; - @override String get audioTrackButton => 'Audio tracks'; - @override String get subtitlesButton => 'Subtitles'; - @override String get chaptersButton => 'Chapters'; - @override String get versionsButton => 'Video versions'; - @override String get aspectRatioButton => 'Aspect ratio'; - @override String get fullscreenButton => 'Enter fullscreen'; - @override String get exitFullscreenButton => 'Exit fullscreen'; - @override String get rotationLockButton => 'Rotation lock'; - @override String get timelineSlider => 'Video timeline'; - @override String get volumeSlider => 'Volume level'; - @override String get backButton => 'Back'; + @override String get playButton => 'Spela'; + @override String get pauseButton => 'Pausa'; + @override String seekBackwardButton({required Object seconds}) => 'Spola bakåt ${seconds} sekunder'; + @override String seekForwardButton({required Object seconds}) => 'Spola framåt ${seconds} sekunder'; + @override String get previousButton => 'Föregående avsnitt'; + @override String get nextButton => 'Nästa avsnitt'; + @override String get previousChapterButton => 'Föregående kapitel'; + @override String get nextChapterButton => 'Nästa kapitel'; + @override String get volumeButton => 'Volym'; + @override String get muteButton => 'Tysta'; + @override String get unmuteButton => 'Slå på ljud'; + @override String get settingsButton => 'Videoinställningar'; + @override String get audioTrackButton => 'Ljudspår'; + @override String get subtitlesButton => 'Undertexter'; + @override String get chaptersButton => 'Kapitel'; + @override String get versionsButton => 'Videoversioner'; + @override String get aspectRatioButton => 'Bildförhållande'; + @override String get fullscreenButton => 'Aktivera helskärm'; + @override String get exitFullscreenButton => 'Avsluta helskärm'; + @override String get rotationLockButton => 'Rotationslås'; + @override String get timelineSlider => 'Videotidslinje'; + @override String get volumeSlider => 'Volymnivå'; + @override String get backButton => 'Tillbaka'; } // Path: userStatus @@ -3879,6 +3897,11 @@ class _StringsServerSelectionSv implements _StringsServerSelectionEn { // Translations @override String get connectingToServer => 'Ansluter till server...'; + @override String get connectingToAllServers => 'Ansluter till alla servrar...'; + @override String get connectToAllServers => 'Anslut till alla servrar'; + @override String get allServerConnectionsFailed => 'Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.'; + @override String foundServers({required Object count}) => 'Hittade ${count} server(ar)'; + @override String get orSelectIndividually => 'Eller välj en server individuellt:'; @override String get serverDebugCopied => 'Server-felsökningsdata kopierad till urklipp'; @override String get copyDebugData => 'Kopiera felsökningsdata'; @override String get noServersFound => 'Inga servrar hittades'; @@ -4378,14 +4401,14 @@ class _StringsAccessibilityZh implements _StringsAccessibilityEn { @override final _StringsZh _root; // ignore: unused_field // Translations - @override String mediaCardMovie({required Object title}) => '${title}, movie'; - @override String mediaCardShow({required Object title}) => '${title}, TV show'; + @override String mediaCardMovie({required Object title}) => '${title}, 电影'; + @override String mediaCardShow({required Object title}) => '${title}, 电视剧'; @override String mediaCardEpisode({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; @override String mediaCardSeason({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - @override String get mediaCardWatched => 'watched'; - @override String mediaCardPartiallyWatched({required Object percent}) => '${percent} percent watched'; - @override String get mediaCardUnwatched => 'unwatched'; - @override String get tapToPlay => 'Tap to play'; + @override String get mediaCardWatched => '已观看'; + @override String mediaCardPartiallyWatched({required Object percent}) => '已观看 ${percent} 百分比'; + @override String get mediaCardUnwatched => '未观看'; + @override String get tapToPlay => '点击播放'; } // Path: tooltips @@ -4424,29 +4447,29 @@ class _StringsVideoControlsZh implements _StringsVideoControlsEn { @override String get timerActive => '定时器已激活'; @override String playbackWillPauseIn({required Object duration}) => '播放将在 ${duration} 后暂停'; @override String get sleepTimerCompleted => '睡眠定时器已完成 - 播放已暂停'; - @override String get playButton => 'Play'; - @override String get pauseButton => 'Pause'; - @override String seekBackwardButton({required Object seconds}) => 'Seek backward ${seconds} seconds'; - @override String seekForwardButton({required Object seconds}) => 'Seek forward ${seconds} seconds'; - @override String get previousButton => 'Previous episode'; - @override String get nextButton => 'Next episode'; - @override String get previousChapterButton => 'Previous chapter'; - @override String get nextChapterButton => 'Next chapter'; - @override String get volumeButton => 'Volume'; - @override String get muteButton => 'Mute'; - @override String get unmuteButton => 'Unmute'; - @override String get settingsButton => 'Video settings'; - @override String get audioTrackButton => 'Audio tracks'; - @override String get subtitlesButton => 'Subtitles'; - @override String get chaptersButton => 'Chapters'; - @override String get versionsButton => 'Video versions'; - @override String get aspectRatioButton => 'Aspect ratio'; - @override String get fullscreenButton => 'Enter fullscreen'; - @override String get exitFullscreenButton => 'Exit fullscreen'; - @override String get rotationLockButton => 'Rotation lock'; - @override String get timelineSlider => 'Video timeline'; - @override String get volumeSlider => 'Volume level'; - @override String get backButton => 'Back'; + @override String get playButton => '播放'; + @override String get pauseButton => '暂停'; + @override String seekBackwardButton({required Object seconds}) => '后退 ${seconds} 秒'; + @override String seekForwardButton({required Object seconds}) => '前进 ${seconds} 秒'; + @override String get previousButton => '上一集'; + @override String get nextButton => '下一集'; + @override String get previousChapterButton => '上一章节'; + @override String get nextChapterButton => '下一章节'; + @override String get volumeButton => '音量'; + @override String get muteButton => '静音'; + @override String get unmuteButton => '取消静音'; + @override String get settingsButton => '视频设置'; + @override String get audioTrackButton => '音轨'; + @override String get subtitlesButton => '字幕'; + @override String get chaptersButton => '章节'; + @override String get versionsButton => '视频版本'; + @override String get aspectRatioButton => '宽高比'; + @override String get fullscreenButton => '进入全屏'; + @override String get exitFullscreenButton => '退出全屏'; + @override String get rotationLockButton => '旋转锁定'; + @override String get timelineSlider => '视频时间轴'; + @override String get volumeSlider => '音量调节'; + @override String get backButton => '返回'; } // Path: userStatus @@ -4664,6 +4687,11 @@ class _StringsServerSelectionZh implements _StringsServerSelectionEn { // Translations @override String get connectingToServer => '正在连接服务器...'; + @override String get connectingToAllServers => '正在连接所有服务器...'; + @override String get connectToAllServers => '连接所有服务器'; + @override String get allServerConnectionsFailed => '无法连接到任何服务器。请检查你的网络并重试。'; + @override String foundServers({required Object count}) => '找到 ${count} 个服务器'; + @override String get orSelectIndividually => '或单独选择一个服务器:'; @override String get serverDebugCopied => '服务器调试数据已复制到剪贴板'; @override String get copyDebugData => '复制调试数据'; @override String get noServersFound => '未找到服务器'; @@ -4882,7 +4910,6 @@ extension on Translations { case 'common.shuffle': return 'Shuffle'; case 'common.addTo': return 'Add to...'; case 'screens.licenses': return 'Licenses'; - case 'screens.selectServer': return 'Select Server'; case 'screens.switchProfile': return 'Switch Profile'; case 'screens.subtitleStyling': return 'Subtitle Styling'; case 'screens.search': return 'Search'; @@ -5106,7 +5133,6 @@ extension on Translations { case 'dialog.playNow': return 'Play Now'; case 'discover.title': return 'Discover'; case 'discover.switchProfile': return 'Switch Profile'; - case 'discover.switchServer': return 'Switch Server'; case 'discover.logout': return 'Logout'; case 'discover.noContentAvailable': return 'No content available'; case 'discover.addMediaToLibraries': return 'Add some media to your libraries'; @@ -5193,6 +5219,11 @@ extension on Translations { case 'about.appDescription': return 'A beautiful Plex client for Flutter'; case 'about.viewLicensesDescription': return 'View licenses of third-party libraries'; case 'serverSelection.connectingToServer': return 'Connecting to server...'; + case 'serverSelection.connectingToAllServers': return 'Connecting to all servers...'; + case 'serverSelection.connectToAllServers': return 'Connect to All Servers'; + case 'serverSelection.allServerConnectionsFailed': return 'Failed to connect to any servers. Please check your network and try again.'; + case 'serverSelection.foundServers': return ({required Object count}) => 'Found ${count} server(s)'; + case 'serverSelection.orSelectIndividually': return 'Or select a server individually:'; case 'serverSelection.serverDebugCopied': return 'Server debug data copied to clipboard'; case 'serverSelection.copyDebugData': return 'Copy Debug Data'; case 'serverSelection.noServersFound': return 'No servers found'; @@ -5640,6 +5671,11 @@ extension on _StringsDe { case 'about.appDescription': return 'Ein schöner Plex-Client für Flutter'; case 'about.viewLicensesDescription': return 'Lizenzen von Drittanbieter-Bibliotheken anzeigen'; case 'serverSelection.connectingToServer': return 'Verbinde mit Server...'; + case 'serverSelection.connectingToAllServers': return 'Verbinde mit allen Servern...'; + case 'serverSelection.connectToAllServers': return 'Mit allen Servern verbinden'; + case 'serverSelection.allServerConnectionsFailed': return 'Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.'; + case 'serverSelection.foundServers': return ({required Object count}) => '${count} Server gefunden'; + case 'serverSelection.orSelectIndividually': return 'Oder einzelnen Server auswählen:'; case 'serverSelection.serverDebugCopied': return 'Server-Debugdaten kopiert'; case 'serverSelection.copyDebugData': return 'Debugdaten kopieren'; case 'serverSelection.noServersFound': return 'Keine Server gefunden'; @@ -5780,7 +5816,7 @@ extension on _StringsIt { case 'screens.switchProfile': return 'Cambia profilo'; case 'screens.subtitleStyling': return 'Stile sottotitoli'; case 'screens.search': return 'Cerca'; - case 'screens.logs': return 'Logs'; + case 'screens.logs': return 'Registro'; case 'update.available': return 'Aggiornamento disponibile'; case 'update.versionAvailable': return ({required Object version}) => 'Versione ${version} disponibile'; case 'update.currentVersion': return ({required Object version}) => 'Corrente: ${version}'; @@ -5901,14 +5937,14 @@ extension on _StringsIt { case 'mediaMenu.goToSeason': return 'Vai alla stagione'; case 'mediaMenu.shufflePlay': return 'Riproduzione casuale'; case 'mediaMenu.fileInfo': return 'Info sul file'; - case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, movie'; - case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV show'; + case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, film'; + case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, serie TV'; case 'accessibility.mediaCardEpisode': return ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; case 'accessibility.mediaCardSeason': return ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - case 'accessibility.mediaCardWatched': return 'watched'; - case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} percent watched'; - case 'accessibility.mediaCardUnwatched': return 'unwatched'; - case 'accessibility.tapToPlay': return 'Tap to play'; + case 'accessibility.mediaCardWatched': return 'visto'; + case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} percento visto'; + case 'accessibility.mediaCardUnwatched': return 'non visto'; + case 'accessibility.tapToPlay': return 'Tocca per riprodurre'; case 'tooltips.shufflePlay': return 'Riproduzione casuale'; case 'tooltips.markAsWatched': return 'Segna come visto'; case 'tooltips.markAsUnwatched': return 'Segna come non visto'; @@ -5919,7 +5955,7 @@ extension on _StringsIt { case 'videoControls.minusTime': return ({required Object amount, required Object unit}) => '-${amount}${unit}'; case 'videoControls.playsLater': return ({required Object label}) => '${label} riprodotto dopo'; case 'videoControls.playsEarlier': return ({required Object label}) => '${label} riprodotto prima'; - case 'videoControls.noOffset': return 'No offset'; + case 'videoControls.noOffset': return 'Nessun offset'; case 'videoControls.letterbox': return 'Letterbox'; case 'videoControls.fillScreen': return 'Riempi schermo'; case 'videoControls.stretch': return 'Allunga'; @@ -5929,29 +5965,29 @@ extension on _StringsIt { case 'videoControls.timerActive': return 'Timer attivo'; case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'La riproduzione si interromperà tra ${duration}'; case 'videoControls.sleepTimerCompleted': return 'Timer di spegnimento completato - riproduzione in pausa'; - case 'videoControls.playButton': return 'Play'; - case 'videoControls.pauseButton': return 'Pause'; - case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; - case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Seek forward ${seconds} seconds'; - case 'videoControls.previousButton': return 'Previous episode'; - case 'videoControls.nextButton': return 'Next episode'; - case 'videoControls.previousChapterButton': return 'Previous chapter'; - case 'videoControls.nextChapterButton': return 'Next chapter'; + case 'videoControls.playButton': return 'Riproduci'; + case 'videoControls.pauseButton': return 'Pausa'; + case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Riavvolgi di ${seconds} secondi'; + case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Avanza di ${seconds} secondi'; + case 'videoControls.previousButton': return 'Episodio precedente'; + case 'videoControls.nextButton': return 'Episodio successivo'; + case 'videoControls.previousChapterButton': return 'Capitolo precedente'; + case 'videoControls.nextChapterButton': return 'Capitolo successivo'; case 'videoControls.volumeButton': return 'Volume'; - case 'videoControls.muteButton': return 'Mute'; - case 'videoControls.unmuteButton': return 'Unmute'; - case 'videoControls.settingsButton': return 'Video settings'; - case 'videoControls.audioTrackButton': return 'Audio tracks'; - case 'videoControls.subtitlesButton': return 'Subtitles'; - case 'videoControls.chaptersButton': return 'Chapters'; - case 'videoControls.versionsButton': return 'Video versions'; - case 'videoControls.aspectRatioButton': return 'Aspect ratio'; - case 'videoControls.fullscreenButton': return 'Enter fullscreen'; - case 'videoControls.exitFullscreenButton': return 'Exit fullscreen'; - case 'videoControls.rotationLockButton': return 'Rotation lock'; - case 'videoControls.timelineSlider': return 'Video timeline'; - case 'videoControls.volumeSlider': return 'Volume level'; - case 'videoControls.backButton': return 'Back'; + case 'videoControls.muteButton': return 'Silenzia'; + case 'videoControls.unmuteButton': return 'Riattiva audio'; + case 'videoControls.settingsButton': return 'Impostazioni video'; + case 'videoControls.audioTrackButton': return 'Tracce audio'; + case 'videoControls.subtitlesButton': return 'Sottotitoli'; + case 'videoControls.chaptersButton': return 'Capitoli'; + case 'videoControls.versionsButton': return 'Versioni video'; + case 'videoControls.aspectRatioButton': return 'Proporzioni'; + case 'videoControls.fullscreenButton': return 'Attiva schermo intero'; + case 'videoControls.exitFullscreenButton': return 'Esci da schermo intero'; + case 'videoControls.rotationLockButton': return 'Blocco rotazione'; + case 'videoControls.timelineSlider': return 'Timeline video'; + case 'videoControls.volumeSlider': return 'Livello volume'; + case 'videoControls.backButton': return 'Indietro'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Limitato'; case 'userStatus.protected': return 'Protetto'; @@ -5998,7 +6034,7 @@ extension on _StringsIt { case 'dialog.areYouSure': return 'Sei sicuro di voler eseguire questa azione?'; case 'dialog.cancel': return 'Cancella'; case 'dialog.playNow': return 'Riproduci ora'; - case 'discover.title': return 'Discover'; + case 'discover.title': return 'Esplora'; case 'discover.switchProfile': return 'Cambia profilo'; case 'discover.switchServer': return 'Cambia server'; case 'discover.logout': return 'Disconnetti'; @@ -6012,7 +6048,7 @@ extension on _StringsIt { case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Riprendi S${season}, E${episode}'; case 'discover.pause': return 'Pausa'; case 'discover.overview': return 'Panoramica'; - case 'discover.cast': return 'Cast'; + case 'discover.cast': return 'Attori'; case 'discover.seasons': return 'Stagioni'; case 'discover.studio': return 'Studio'; case 'discover.rating': return 'Classificazione'; @@ -6087,6 +6123,11 @@ extension on _StringsIt { case 'about.appDescription': return 'Un bellissimo client Plex per Flutter'; case 'about.viewLicensesDescription': return 'Visualizza le licenze delle librerie di terze parti'; case 'serverSelection.connectingToServer': return 'Connessione al server...'; + case 'serverSelection.connectingToAllServers': return 'Connessione a tutti i server...'; + case 'serverSelection.connectToAllServers': return 'Connetti a tutti i server'; + case 'serverSelection.allServerConnectionsFailed': return 'Impossibile connettersi a nessun server. Controlla la tua rete e riprova.'; + case 'serverSelection.foundServers': return ({required Object count}) => 'Trovati ${count} server'; + case 'serverSelection.orSelectIndividually': return 'Oppure seleziona un server singolarmente:'; case 'serverSelection.serverDebugCopied': return 'Dati di debug del server copiati negli appunti'; case 'serverSelection.copyDebugData': return 'Copia dati di debug'; case 'serverSelection.noServersFound': return 'Nessun server trovato'; @@ -6103,7 +6144,7 @@ extension on _StringsIt { case 'hubDetail.dateAdded': return 'Data aggiunta'; case 'hubDetail.rating': return 'Valutazione'; case 'hubDetail.noItemsFound': return 'Nessun elemento trovato'; - case 'logs.title': return 'Log'; + case 'logs.title': return 'Registro'; case 'logs.clearLogs': return 'Cancella log'; case 'logs.copyLogs': return 'Copia log'; case 'logs.exportLogs': return 'Esporta log'; @@ -6193,7 +6234,7 @@ extension on _StringsNl { case 'auth.authenticate': return 'Authenticeren'; case 'auth.retry': return 'Opnieuw proberen'; case 'auth.debugEnterToken': return 'Debug: Voer Plex Token in'; - case 'auth.plexTokenLabel': return 'Plex Auth Token'; + case 'auth.plexTokenLabel': return 'Plex Authenticatietoken'; case 'auth.plexTokenHint': return 'Voer je Plex.tv token in'; case 'auth.authenticationTimeout': return 'Authenticatie verlopen. Probeer opnieuw.'; case 'auth.scanQRCodeInstruction': return 'Scan deze QR-code met een apparaat dat is ingelogd op Plex om te authenticeren.'; @@ -6220,14 +6261,14 @@ extension on _StringsNl { case 'common.no': return 'Nee'; case 'common.server': return 'Server'; case 'common.delete': return 'Verwijderen'; - case 'common.shuffle': return 'Shuffle'; + case 'common.shuffle': return 'Willekeurig'; case 'common.addTo': return 'Toevoegen aan...'; case 'screens.licenses': return 'Licenties'; case 'screens.selectServer': return 'Selecteer server'; case 'screens.switchProfile': return 'Wissel van profiel'; case 'screens.subtitleStyling': return 'Ondertitel opmaak'; case 'screens.search': return 'Zoeken'; - case 'screens.logs': return 'Logs'; + case 'screens.logs': return 'Logbestanden'; case 'update.available': return 'Update beschikbaar'; case 'update.versionAvailable': return ({required Object version}) => 'Versie ${version} is beschikbaar'; case 'update.currentVersion': return ({required Object version}) => 'Huidig: ${version}'; @@ -6348,14 +6389,14 @@ extension on _StringsNl { case 'mediaMenu.goToSeason': return 'Ga naar seizoen'; case 'mediaMenu.shufflePlay': return 'Willekeurig afspelen'; case 'mediaMenu.fileInfo': return 'Bestand info'; - case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, movie'; - case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV show'; + case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, film'; + case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV-serie'; case 'accessibility.mediaCardEpisode': return ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; case 'accessibility.mediaCardSeason': return ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - case 'accessibility.mediaCardWatched': return 'watched'; - case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} percent watched'; - case 'accessibility.mediaCardUnwatched': return 'unwatched'; - case 'accessibility.tapToPlay': return 'Tap to play'; + case 'accessibility.mediaCardWatched': return 'bekeken'; + case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} procent bekeken'; + case 'accessibility.mediaCardUnwatched': return 'niet bekeken'; + case 'accessibility.tapToPlay': return 'Tik om af te spelen'; case 'tooltips.shufflePlay': return 'Willekeurig afspelen'; case 'tooltips.markAsWatched': return 'Markeer als gekeken'; case 'tooltips.markAsUnwatched': return 'Markeer als ongekeken'; @@ -6376,29 +6417,29 @@ extension on _StringsNl { case 'videoControls.timerActive': return 'Timer actief'; case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Afspelen wordt gepauzeerd over ${duration}'; case 'videoControls.sleepTimerCompleted': return 'Slaaptimer voltooid - afspelen gepauzeerd'; - case 'videoControls.playButton': return 'Play'; - case 'videoControls.pauseButton': return 'Pause'; - case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; - case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Seek forward ${seconds} seconds'; - case 'videoControls.previousButton': return 'Previous episode'; - case 'videoControls.nextButton': return 'Next episode'; - case 'videoControls.previousChapterButton': return 'Previous chapter'; - case 'videoControls.nextChapterButton': return 'Next chapter'; + case 'videoControls.playButton': return 'Afspelen'; + case 'videoControls.pauseButton': return 'Pauzeren'; + case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Terugspoelen ${seconds} seconden'; + case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Vooruitspoelen ${seconds} seconden'; + case 'videoControls.previousButton': return 'Vorige aflevering'; + case 'videoControls.nextButton': return 'Volgende aflevering'; + case 'videoControls.previousChapterButton': return 'Vorig hoofdstuk'; + case 'videoControls.nextChapterButton': return 'Volgend hoofdstuk'; case 'videoControls.volumeButton': return 'Volume'; - case 'videoControls.muteButton': return 'Mute'; - case 'videoControls.unmuteButton': return 'Unmute'; - case 'videoControls.settingsButton': return 'Video settings'; - case 'videoControls.audioTrackButton': return 'Audio tracks'; - case 'videoControls.subtitlesButton': return 'Subtitles'; - case 'videoControls.chaptersButton': return 'Chapters'; - case 'videoControls.versionsButton': return 'Video versions'; - case 'videoControls.aspectRatioButton': return 'Aspect ratio'; - case 'videoControls.fullscreenButton': return 'Enter fullscreen'; - case 'videoControls.exitFullscreenButton': return 'Exit fullscreen'; - case 'videoControls.rotationLockButton': return 'Rotation lock'; - case 'videoControls.timelineSlider': return 'Video timeline'; - case 'videoControls.volumeSlider': return 'Volume level'; - case 'videoControls.backButton': return 'Back'; + case 'videoControls.muteButton': return 'Dempen'; + case 'videoControls.unmuteButton': return 'Dempen opheffen'; + case 'videoControls.settingsButton': return 'Video-instellingen'; + case 'videoControls.audioTrackButton': return 'Audiosporen'; + case 'videoControls.subtitlesButton': return 'Ondertitels'; + case 'videoControls.chaptersButton': return 'Hoofdstukken'; + case 'videoControls.versionsButton': return 'Videoversies'; + case 'videoControls.aspectRatioButton': return 'Beeldverhouding'; + case 'videoControls.fullscreenButton': return 'Volledig scherm activeren'; + case 'videoControls.exitFullscreenButton': return 'Volledig scherm verlaten'; + case 'videoControls.rotationLockButton': return 'Rotatievergrendeling'; + case 'videoControls.timelineSlider': return 'Videotijdlijn'; + case 'videoControls.volumeSlider': return 'Volumeniveau'; + case 'videoControls.backButton': return 'Terug'; case 'userStatus.admin': return 'Beheerder'; case 'userStatus.restricted': return 'Beperkt'; case 'userStatus.protected': return 'Beschermd'; @@ -6459,7 +6500,7 @@ extension on _StringsNl { case 'discover.resumeEpisode': return ({required Object season, required Object episode}) => 'Hervat S${season}, E${episode}'; case 'discover.pause': return 'Pauzeren'; case 'discover.overview': return 'Overzicht'; - case 'discover.cast': return 'Cast'; + case 'discover.cast': return 'Acteurs'; case 'discover.seasons': return 'Seizoenen'; case 'discover.studio': return 'Studio'; case 'discover.rating': return 'Leeftijd'; @@ -6534,6 +6575,11 @@ extension on _StringsNl { case 'about.appDescription': return 'Een mooie Plex client voor Flutter'; case 'about.viewLicensesDescription': return 'Bekijk licenties van third-party bibliotheken'; case 'serverSelection.connectingToServer': return 'Verbinden met server...'; + case 'serverSelection.connectingToAllServers': return 'Verbinden met alle servers...'; + case 'serverSelection.connectToAllServers': return 'Verbind met alle servers'; + case 'serverSelection.allServerConnectionsFailed': return 'Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.'; + case 'serverSelection.foundServers': return ({required Object count}) => '${count} server(s) gevonden'; + case 'serverSelection.orSelectIndividually': return 'Of selecteer een server individueel:'; case 'serverSelection.serverDebugCopied': return 'Server debug gegevens gekopieerd naar klembord'; case 'serverSelection.copyDebugData': return 'Kopieer debug gegevens'; case 'serverSelection.noServersFound': return 'Geen servers gevonden'; @@ -6550,18 +6596,18 @@ extension on _StringsNl { case 'hubDetail.dateAdded': return 'Datum toegevoegd'; case 'hubDetail.rating': return 'Beoordeling'; case 'hubDetail.noItemsFound': return 'Geen items gevonden'; - case 'logs.title': return 'Logs'; + case 'logs.title': return 'Logbestanden'; case 'logs.clearLogs': return 'Wis logs'; case 'logs.copyLogs': return 'Kopieer logs'; case 'logs.exportLogs': return 'Exporteer logs'; case 'logs.noLogsToShow': return 'Geen logs om te tonen'; case 'logs.error': return 'Fout:'; - case 'logs.stackTrace': return 'Stack Trace:'; + case 'logs.stackTrace': return 'Stacktracering:'; case 'licenses.relatedPackages': return 'Gerelateerde pakketten'; case 'licenses.license': return 'Licentie'; case 'licenses.licenseNumber': return ({required Object number}) => 'Licentie ${number}'; case 'licenses.licensesCount': return ({required Object count}) => '${count} licenties'; - case 'navigation.home': return 'Home'; + case 'navigation.home': return 'Thuis'; case 'navigation.search': return 'Zoeken'; case 'navigation.libraries': return 'Bibliotheken'; case 'navigation.settings': return 'Instellingen'; @@ -6578,7 +6624,7 @@ extension on _StringsNl { case 'playlists.removeItem': return 'Verwijderen uit afspeellijst'; case 'playlists.clearPlaylist': return 'Afspeellijst wissen'; case 'playlists.playAll': return 'Alles afspelen'; - case 'playlists.shuffle': return 'Shuffle'; + case 'playlists.shuffle': return 'Willekeurig'; case 'playlists.smartPlaylist': return 'Slimme afspeellijst'; case 'playlists.regularPlaylist': return 'Normale afspeellijst'; case 'playlists.itemCount': return ({required Object count}) => '${count} items'; @@ -6795,14 +6841,14 @@ extension on _StringsSv { case 'mediaMenu.goToSeason': return 'Gå till säsong'; case 'mediaMenu.shufflePlay': return 'Blanda uppspelning'; case 'mediaMenu.fileInfo': return 'Filinformation'; - case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, movie'; - case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV show'; + case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, film'; + case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV-serie'; case 'accessibility.mediaCardEpisode': return ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; case 'accessibility.mediaCardSeason': return ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - case 'accessibility.mediaCardWatched': return 'watched'; - case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} percent watched'; - case 'accessibility.mediaCardUnwatched': return 'unwatched'; - case 'accessibility.tapToPlay': return 'Tap to play'; + case 'accessibility.mediaCardWatched': return 'sedd'; + case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} procent sedd'; + case 'accessibility.mediaCardUnwatched': return 'osedd'; + case 'accessibility.tapToPlay': return 'Tryck för att spela'; case 'tooltips.shufflePlay': return 'Blanda uppspelning'; case 'tooltips.markAsWatched': return 'Markera som sedd'; case 'tooltips.markAsUnwatched': return 'Markera som osedd'; @@ -6823,29 +6869,29 @@ extension on _StringsSv { case 'videoControls.timerActive': return 'Timer aktiv'; case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => 'Uppspelningen pausas om ${duration}'; case 'videoControls.sleepTimerCompleted': return 'Sovtimer slutförd - uppspelning pausad'; - case 'videoControls.playButton': return 'Play'; - case 'videoControls.pauseButton': return 'Pause'; - case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; - case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Seek forward ${seconds} seconds'; - case 'videoControls.previousButton': return 'Previous episode'; - case 'videoControls.nextButton': return 'Next episode'; - case 'videoControls.previousChapterButton': return 'Previous chapter'; - case 'videoControls.nextChapterButton': return 'Next chapter'; - case 'videoControls.volumeButton': return 'Volume'; - case 'videoControls.muteButton': return 'Mute'; - case 'videoControls.unmuteButton': return 'Unmute'; - case 'videoControls.settingsButton': return 'Video settings'; - case 'videoControls.audioTrackButton': return 'Audio tracks'; - case 'videoControls.subtitlesButton': return 'Subtitles'; - case 'videoControls.chaptersButton': return 'Chapters'; - case 'videoControls.versionsButton': return 'Video versions'; - case 'videoControls.aspectRatioButton': return 'Aspect ratio'; - case 'videoControls.fullscreenButton': return 'Enter fullscreen'; - case 'videoControls.exitFullscreenButton': return 'Exit fullscreen'; - case 'videoControls.rotationLockButton': return 'Rotation lock'; - case 'videoControls.timelineSlider': return 'Video timeline'; - case 'videoControls.volumeSlider': return 'Volume level'; - case 'videoControls.backButton': return 'Back'; + case 'videoControls.playButton': return 'Spela'; + case 'videoControls.pauseButton': return 'Pausa'; + case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Spola bakåt ${seconds} sekunder'; + case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Spola framåt ${seconds} sekunder'; + case 'videoControls.previousButton': return 'Föregående avsnitt'; + case 'videoControls.nextButton': return 'Nästa avsnitt'; + case 'videoControls.previousChapterButton': return 'Föregående kapitel'; + case 'videoControls.nextChapterButton': return 'Nästa kapitel'; + case 'videoControls.volumeButton': return 'Volym'; + case 'videoControls.muteButton': return 'Tysta'; + case 'videoControls.unmuteButton': return 'Slå på ljud'; + case 'videoControls.settingsButton': return 'Videoinställningar'; + case 'videoControls.audioTrackButton': return 'Ljudspår'; + case 'videoControls.subtitlesButton': return 'Undertexter'; + case 'videoControls.chaptersButton': return 'Kapitel'; + case 'videoControls.versionsButton': return 'Videoversioner'; + case 'videoControls.aspectRatioButton': return 'Bildförhållande'; + case 'videoControls.fullscreenButton': return 'Aktivera helskärm'; + case 'videoControls.exitFullscreenButton': return 'Avsluta helskärm'; + case 'videoControls.rotationLockButton': return 'Rotationslås'; + case 'videoControls.timelineSlider': return 'Videotidslinje'; + case 'videoControls.volumeSlider': return 'Volymnivå'; + case 'videoControls.backButton': return 'Tillbaka'; case 'userStatus.admin': return 'Admin'; case 'userStatus.restricted': return 'Begränsad'; case 'userStatus.protected': return 'Skyddad'; @@ -6981,6 +7027,11 @@ extension on _StringsSv { case 'about.appDescription': return 'En vacker Plex-klient för Flutter'; case 'about.viewLicensesDescription': return 'Visa licenser för tredjepartsbibliotek'; case 'serverSelection.connectingToServer': return 'Ansluter till server...'; + case 'serverSelection.connectingToAllServers': return 'Ansluter till alla servrar...'; + case 'serverSelection.connectToAllServers': return 'Anslut till alla servrar'; + case 'serverSelection.allServerConnectionsFailed': return 'Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.'; + case 'serverSelection.foundServers': return ({required Object count}) => 'Hittade ${count} server(ar)'; + case 'serverSelection.orSelectIndividually': return 'Eller välj en server individuellt:'; case 'serverSelection.serverDebugCopied': return 'Server-felsökningsdata kopierad till urklipp'; case 'serverSelection.copyDebugData': return 'Kopiera felsökningsdata'; case 'serverSelection.noServersFound': return 'Inga servrar hittades'; @@ -7242,14 +7293,14 @@ extension on _StringsZh { case 'mediaMenu.goToSeason': return '转到季'; case 'mediaMenu.shufflePlay': return '随机播放'; case 'mediaMenu.fileInfo': return '文件信息'; - case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, movie'; - case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, TV show'; + case 'accessibility.mediaCardMovie': return ({required Object title}) => '${title}, 电影'; + case 'accessibility.mediaCardShow': return ({required Object title}) => '${title}, 电视剧'; case 'accessibility.mediaCardEpisode': return ({required Object title, required Object episodeInfo}) => '${title}, ${episodeInfo}'; case 'accessibility.mediaCardSeason': return ({required Object title, required Object seasonInfo}) => '${title}, ${seasonInfo}'; - case 'accessibility.mediaCardWatched': return 'watched'; - case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '${percent} percent watched'; - case 'accessibility.mediaCardUnwatched': return 'unwatched'; - case 'accessibility.tapToPlay': return 'Tap to play'; + case 'accessibility.mediaCardWatched': return '已观看'; + case 'accessibility.mediaCardPartiallyWatched': return ({required Object percent}) => '已观看 ${percent} 百分比'; + case 'accessibility.mediaCardUnwatched': return '未观看'; + case 'accessibility.tapToPlay': return '点击播放'; case 'tooltips.shufflePlay': return '随机播放'; case 'tooltips.markAsWatched': return '标记为已观看'; case 'tooltips.markAsUnwatched': return '标记为未观看'; @@ -7270,29 +7321,29 @@ extension on _StringsZh { case 'videoControls.timerActive': return '定时器已激活'; case 'videoControls.playbackWillPauseIn': return ({required Object duration}) => '播放将在 ${duration} 后暂停'; case 'videoControls.sleepTimerCompleted': return '睡眠定时器已完成 - 播放已暂停'; - case 'videoControls.playButton': return 'Play'; - case 'videoControls.pauseButton': return 'Pause'; - case 'videoControls.seekBackwardButton': return ({required Object seconds}) => 'Seek backward ${seconds} seconds'; - case 'videoControls.seekForwardButton': return ({required Object seconds}) => 'Seek forward ${seconds} seconds'; - case 'videoControls.previousButton': return 'Previous episode'; - case 'videoControls.nextButton': return 'Next episode'; - case 'videoControls.previousChapterButton': return 'Previous chapter'; - case 'videoControls.nextChapterButton': return 'Next chapter'; - case 'videoControls.volumeButton': return 'Volume'; - case 'videoControls.muteButton': return 'Mute'; - case 'videoControls.unmuteButton': return 'Unmute'; - case 'videoControls.settingsButton': return 'Video settings'; - case 'videoControls.audioTrackButton': return 'Audio tracks'; - case 'videoControls.subtitlesButton': return 'Subtitles'; - case 'videoControls.chaptersButton': return 'Chapters'; - case 'videoControls.versionsButton': return 'Video versions'; - case 'videoControls.aspectRatioButton': return 'Aspect ratio'; - case 'videoControls.fullscreenButton': return 'Enter fullscreen'; - case 'videoControls.exitFullscreenButton': return 'Exit fullscreen'; - case 'videoControls.rotationLockButton': return 'Rotation lock'; - case 'videoControls.timelineSlider': return 'Video timeline'; - case 'videoControls.volumeSlider': return 'Volume level'; - case 'videoControls.backButton': return 'Back'; + case 'videoControls.playButton': return '播放'; + case 'videoControls.pauseButton': return '暂停'; + case 'videoControls.seekBackwardButton': return ({required Object seconds}) => '后退 ${seconds} 秒'; + case 'videoControls.seekForwardButton': return ({required Object seconds}) => '前进 ${seconds} 秒'; + case 'videoControls.previousButton': return '上一集'; + case 'videoControls.nextButton': return '下一集'; + case 'videoControls.previousChapterButton': return '上一章节'; + case 'videoControls.nextChapterButton': return '下一章节'; + case 'videoControls.volumeButton': return '音量'; + case 'videoControls.muteButton': return '静音'; + case 'videoControls.unmuteButton': return '取消静音'; + case 'videoControls.settingsButton': return '视频设置'; + case 'videoControls.audioTrackButton': return '音轨'; + case 'videoControls.subtitlesButton': return '字幕'; + case 'videoControls.chaptersButton': return '章节'; + case 'videoControls.versionsButton': return '视频版本'; + case 'videoControls.aspectRatioButton': return '宽高比'; + case 'videoControls.fullscreenButton': return '进入全屏'; + case 'videoControls.exitFullscreenButton': return '退出全屏'; + case 'videoControls.rotationLockButton': return '旋转锁定'; + case 'videoControls.timelineSlider': return '视频时间轴'; + case 'videoControls.volumeSlider': return '音量调节'; + case 'videoControls.backButton': return '返回'; case 'userStatus.admin': return '管理员'; case 'userStatus.restricted': return '受限'; case 'userStatus.protected': return '受保护'; @@ -7428,6 +7479,11 @@ extension on _StringsZh { case 'about.appDescription': return '一款精美的 Flutter Plex 客户端'; case 'about.viewLicensesDescription': return '查看第三方库的许可证'; case 'serverSelection.connectingToServer': return '正在连接服务器...'; + case 'serverSelection.connectingToAllServers': return '正在连接所有服务器...'; + case 'serverSelection.connectToAllServers': return '连接所有服务器'; + case 'serverSelection.allServerConnectionsFailed': return '无法连接到任何服务器。请检查你的网络并重试。'; + case 'serverSelection.foundServers': return ({required Object count}) => '找到 ${count} 个服务器'; + case 'serverSelection.orSelectIndividually': return '或单独选择一个服务器:'; case 'serverSelection.serverDebugCopied': return '服务器调试数据已复制到剪贴板'; case 'serverSelection.copyDebugData': return '复制调试数据'; case 'serverSelection.noServersFound': return '未找到服务器'; diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 6f5cd309..99d1f4a2 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -44,7 +44,6 @@ }, "screens": { "licenses": "Licenses", - "selectServer": "Select Server", "switchProfile": "Switch Profile", "subtitleStyling": "Subtitle Styling", "search": "Search", @@ -300,7 +299,6 @@ "discover": { "title": "Discover", "switchProfile": "Switch Profile", - "switchServer": "Switch Server", "logout": "Logout", "noContentAvailable": "No content available", "addMediaToLibraries": "Add some media to your libraries", @@ -399,6 +397,11 @@ }, "serverSelection": { "connectingToServer": "Connecting to server...", + "connectingToAllServers": "Connecting to all servers...", + "connectToAllServers": "Connect to All Servers", + "allServerConnectionsFailed": "Failed to connect to any servers. Please check your network and try again.", + "foundServers": "Found ${count} server(s)", + "orSelectIndividually": "Or select a server individually:", "serverDebugCopied": "Server debug data copied to clipboard", "copyDebugData": "Copy Debug Data", "noServersFound": "No servers found", diff --git a/lib/i18n/strings_de.i18n.json b/lib/i18n/strings_de.i18n.json index eec82326..c1e36df3 100644 --- a/lib/i18n/strings_de.i18n.json +++ b/lib/i18n/strings_de.i18n.json @@ -399,6 +399,11 @@ }, "serverSelection": { "connectingToServer": "Verbinde mit Server...", + "connectingToAllServers": "Verbinde mit allen Servern...", + "connectToAllServers": "Mit allen Servern verbinden", + "allServerConnectionsFailed": "Verbindung zu allen Servern fehlgeschlagen. Bitte Netzwerk prüfen und erneut versuchen.", + "foundServers": "${count} Server gefunden", + "orSelectIndividually": "Oder einzelnen Server auswählen:", "serverDebugCopied": "Server-Debugdaten kopiert", "copyDebugData": "Debugdaten kopieren", "noServersFound": "Keine Server gefunden", diff --git a/lib/i18n/strings_it.i18n.json b/lib/i18n/strings_it.i18n.json index 1109e7d5..e298f688 100644 --- a/lib/i18n/strings_it.i18n.json +++ b/lib/i18n/strings_it.i18n.json @@ -399,6 +399,11 @@ }, "serverSelection": { "connectingToServer": "Connessione al server...", + "connectingToAllServers": "Connessione a tutti i server...", + "connectToAllServers": "Connetti a tutti i server", + "allServerConnectionsFailed": "Impossibile connettersi a nessun server. Controlla la tua rete e riprova.", + "foundServers": "Trovati ${count} server", + "orSelectIndividually": "Oppure seleziona un server singolarmente:", "serverDebugCopied": "Dati di debug del server copiati negli appunti", "copyDebugData": "Copia dati di debug", "noServersFound": "Nessun server trovato", diff --git a/lib/i18n/strings_nl.i18n.json b/lib/i18n/strings_nl.i18n.json index c6d227c3..02360f8f 100644 --- a/lib/i18n/strings_nl.i18n.json +++ b/lib/i18n/strings_nl.i18n.json @@ -399,6 +399,11 @@ }, "serverSelection": { "connectingToServer": "Verbinden met server...", + "connectingToAllServers": "Verbinden met alle servers...", + "connectToAllServers": "Verbind met alle servers", + "allServerConnectionsFailed": "Kon niet verbinden met servers. Controleer je netwerk en probeer opnieuw.", + "foundServers": "${count} server(s) gevonden", + "orSelectIndividually": "Of selecteer een server individueel:", "serverDebugCopied": "Server debug gegevens gekopieerd naar klembord", "copyDebugData": "Kopieer debug gegevens", "noServersFound": "Geen servers gevonden", diff --git a/lib/i18n/strings_sv.i18n.json b/lib/i18n/strings_sv.i18n.json index f329f26a..a8d5a0a5 100644 --- a/lib/i18n/strings_sv.i18n.json +++ b/lib/i18n/strings_sv.i18n.json @@ -399,6 +399,11 @@ }, "serverSelection": { "connectingToServer": "Ansluter till server...", + "connectingToAllServers": "Ansluter till alla servrar...", + "connectToAllServers": "Anslut till alla servrar", + "allServerConnectionsFailed": "Misslyckades att ansluta till servrar. Kontrollera ditt nätverk och försök igen.", + "foundServers": "Hittade ${count} server(ar)", + "orSelectIndividually": "Eller välj en server individuellt:", "serverDebugCopied": "Server-felsökningsdata kopierad till urklipp", "copyDebugData": "Kopiera felsökningsdata", "noServersFound": "Inga servrar hittades", diff --git a/lib/i18n/strings_zh.i18n.json b/lib/i18n/strings_zh.i18n.json index 249c8d87..10b5133c 100644 --- a/lib/i18n/strings_zh.i18n.json +++ b/lib/i18n/strings_zh.i18n.json @@ -399,6 +399,11 @@ }, "serverSelection": { "connectingToServer": "正在连接服务器...", + "connectingToAllServers": "正在连接所有服务器...", + "connectToAllServers": "连接所有服务器", + "allServerConnectionsFailed": "无法连接到任何服务器。请检查你的网络并重试。", + "foundServers": "找到 ${count} 个服务器", + "orSelectIndividually": "或单独选择一个服务器:", "serverDebugCopied": "服务器调试数据已复制到剪贴板", "copyDebugData": "复制调试数据", "noServersFound": "未找到服务器", diff --git a/lib/main.dart b/lib/main.dart index 0a8a041b..e46d5840 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,10 +15,15 @@ import 'services/update_service.dart'; import 'services/settings_service.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; +import 'providers/multi_server_provider.dart'; +import 'providers/server_state_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/hidden_libraries_provider.dart'; import 'providers/playback_state_provider.dart'; +import 'services/multi_server_manager.dart'; +import 'services/data_aggregation_service.dart'; +import 'services/server_registry.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; @@ -77,9 +82,20 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { + // Initialize multi-server infrastructure + final serverManager = MultiServerManager(); + final aggregationService = DataAggregationService(serverManager); + return MultiProvider( providers: [ + // Legacy provider for backward compatibility ChangeNotifierProvider(create: (context) => PlexClientProvider()), + // New multi-server providers + ChangeNotifierProvider( + create: (context) => MultiServerProvider(serverManager, aggregationService), + ), + ChangeNotifierProvider(create: (context) => ServerStateProvider()), + // Existing providers ChangeNotifierProvider( create: (context) => UserProfileProvider()..initialize(), ), @@ -219,86 +235,99 @@ class _SetupScreenState extends State { Future _loadSavedCredentials() async { final storage = await StorageService.getInstance(); + final registry = ServerRegistry(storage); - // Check if we have server data - final serverData = storage.getServerData(); - final clientId = storage.getClientIdentifier(); - final plexToken = storage.getPlexToken(); + // Migrate from single-server to multi-server if needed + await registry.migrateFromSingleServer(); - // Get current user's server token (prioritize over original server token) - final currentUserToken = storage.getToken(); + // Load enabled servers + final servers = await registry.getEnabledServers(); - if (serverData != null && clientId != null) { - try { - // Recreate PlexServer from stored data - final server = PlexServer.fromJson(serverData); - - // Use current user's token if available, fallback to server's original token - final tokenToUse = currentUserToken ?? server.accessToken; - - appLogger.d( - 'App startup token selection: currentUserToken=${currentUserToken != null ? 'present' : 'null'}, using=${currentUserToken != null ? 'current user' : 'original server'} token', + if (servers.isEmpty) { + // No servers configured - show auth screen + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), ); - - // Create updated server with correct token for current user - final serverWithCurrentToken = PlexServer( - name: server.name, - clientIdentifier: server.clientIdentifier, - accessToken: tokenToUse, - connections: server.connections, - owned: server.owned, - product: server.product, - platform: server.platform, - lastSeenAt: server.lastSeenAt, - presence: server.presence, - ); - - // Connect using the optimized service with current user's token - final result = await ServerConnectionService.connectToServer( - serverWithCurrentToken, - clientIdentifier: clientId, - verifyServer: true, - fetchUserProfile: plexToken != null, - plexToken: plexToken, - ); - - // Handle result - if (result.isSuccess) { - // Success! Set client in provider - if (mounted) { - context.plexClient.setClient(result.client!); - - // Check for updates BEFORE navigation to keep context valid - _checkForUpdatesOnStartup(); - - // Navigate to main screen after update check is initiated - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => MainScreen(client: result.client!), - ), - ); - } - } - return; - } else { - // Connection failed, clear credentials - await storage.clearCredentials(); - } - } catch (e) { - // Error loading or testing server - appLogger.e('Error during auto-login', error: e); - await storage.clearCredentials(); } + return; } - // No saved credentials or auto-login failed - show auth screen - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const AuthScreen()), + // Get multi-server provider + if (!mounted) return; + final multiServerProvider = Provider.of( + context, + listen: false, + ); + + try { + appLogger.i('Connecting to ${servers.length} enabled servers...'); + + // Get or generate client identifier + final clientId = storage.getClientIdentifier(); + + // Connect to all servers in parallel + final connectedCount = await multiServerProvider.serverManager.connectToAllServers( + servers, + clientIdentifier: clientId, + timeout: const Duration(seconds: 10), + onServerConnected: (serverId, client) { + // Set first connected client in legacy provider for backward compatibility + final legacyProvider = Provider.of( + context, + listen: false, + ); + if (legacyProvider.client == null) { + legacyProvider.setClient(client); + } + }, ); + + if (connectedCount > 0) { + // At least one server connected successfully + appLogger.i('Successfully connected to $connectedCount servers'); + + if (mounted) { + // Check for updates BEFORE navigation + _checkForUpdatesOnStartup(); + + // Navigate to main screen + // Get first connected client for backward compatibility + final firstClient = multiServerProvider.serverManager.onlineClients.values.first; + + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => MainScreen(client: firstClient), + ), + ); + } + } else { + // All connections failed + appLogger.w('Failed to connect to any servers'); + + if (mounted) { + // Show auth screen to re-authenticate + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), + ); + } + } + } catch (e, stackTrace) { + appLogger.e( + 'Error during multi-server connection', + error: e, + stackTrace: stackTrace, + ); + + if (mounted) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), + ); + } } } diff --git a/lib/models/plex_hub.dart b/lib/models/plex_hub.dart index 27fe3c86..da7bd957 100644 --- a/lib/models/plex_hub.dart +++ b/lib/models/plex_hub.dart @@ -10,6 +10,10 @@ class PlexHub { final bool more; final List items; + // Multi-server support fields + final String? serverId; // Server machine identifier + final String? serverName; // Server display name + PlexHub({ required this.hubKey, required this.title, @@ -18,6 +22,8 @@ class PlexHub { required this.size, required this.more, required this.items, + this.serverId, + this.serverName, }); factory PlexHub.fromJson(Map json) { diff --git a/lib/models/plex_library.dart b/lib/models/plex_library.dart index 34db55b4..632a1c3c 100644 --- a/lib/models/plex_library.dart +++ b/lib/models/plex_library.dart @@ -15,6 +15,15 @@ class PlexLibrary { final int? createdAt; final int? hidden; + // Multi-server support fields + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverId; // Server machine identifier (not from API) + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverName; // Server display name (not from API) + + /// Global unique identifier across all servers (serverId:key) + String get globalKey => serverId != null ? '$serverId:$key' : key; + PlexLibrary({ required this.key, required this.title, @@ -26,6 +35,8 @@ class PlexLibrary { this.updatedAt, this.createdAt, this.hidden, + this.serverId, + this.serverName, }); factory PlexLibrary.fromJson(Map json) => diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 7274b83c..98adfc41 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -45,10 +45,19 @@ class PlexMetadata { final int? playQueueItemID; // Play queue item ID (unique even for duplicates) final int? librarySectionID; // Library section ID this item belongs to + // Multi-server support fields + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverId; // Server machine identifier (not from API) + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverName; // Server display name (not from API) + // Transient field for clear logo (extracted from Image array) String? _clearLogo; String? get clearLogo => _clearLogo; + /// Global unique identifier across all servers (serverId:ratingKey) + String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey; + PlexMetadata({ required this.ratingKey, required this.key, @@ -87,6 +96,8 @@ class PlexMetadata { this.playlistItemID, this.playQueueItemID, this.librarySectionID, + this.serverId, + this.serverName, }); /// Create a copy of this metadata with optional field overrides @@ -128,6 +139,8 @@ class PlexMetadata { int? playlistItemID, int? playQueueItemID, int? librarySectionID, + String? serverId, + String? serverName, }) { final copy = PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -167,6 +180,8 @@ class PlexMetadata { playlistItemID: playlistItemID ?? this.playlistItemID, playQueueItemID: playQueueItemID ?? this.playQueueItemID, librarySectionID: librarySectionID ?? this.librarySectionID, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, ); // Preserve clearLogo copy._clearLogo = _clearLogo; diff --git a/lib/models/plex_playlist.dart b/lib/models/plex_playlist.dart index 6e788473..0c352f1b 100644 --- a/lib/models/plex_playlist.dart +++ b/lib/models/plex_playlist.dart @@ -22,6 +22,12 @@ class PlexPlaylist { final String? guid; final String? thumb; + // Multi-server support: Track which server this playlist belongs to + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverId; + @JsonKey(includeFromJson: false, includeToJson: false) + final String? serverName; + PlexPlaylist({ required this.ratingKey, required this.key, @@ -40,6 +46,8 @@ class PlexPlaylist { this.content, this.guid, this.thumb, + this.serverId, + this.serverName, }); /// Helper to get display image (composite or thumb) @@ -51,6 +59,9 @@ class PlexPlaylist { /// Helper to determine if playlist is editable bool get isEditable => !smart; + /// Get globally unique key across all servers + String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey; + // Properties for MediaCard compatibility with PlexMetadata interface /// Playlists are not "watched" in the traditional sense @@ -83,4 +94,49 @@ class PlexPlaylist { _$PlexPlaylistFromJson(json); Map toJson() => _$PlexPlaylistToJson(this); + + /// Create a copy with optional field updates + PlexPlaylist copyWith({ + String? ratingKey, + String? key, + String? type, + String? title, + String? summary, + bool? smart, + String? playlistType, + int? duration, + int? leafCount, + String? composite, + int? addedAt, + int? updatedAt, + int? lastViewedAt, + int? viewCount, + String? content, + String? guid, + String? thumb, + String? serverId, + String? serverName, + }) { + return PlexPlaylist( + ratingKey: ratingKey ?? this.ratingKey, + key: key ?? this.key, + type: type ?? this.type, + title: title ?? this.title, + summary: summary ?? this.summary, + smart: smart ?? this.smart, + playlistType: playlistType ?? this.playlistType, + duration: duration ?? this.duration, + leafCount: leafCount ?? this.leafCount, + composite: composite ?? this.composite, + addedAt: addedAt ?? this.addedAt, + updatedAt: updatedAt ?? this.updatedAt, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, + viewCount: viewCount ?? this.viewCount, + content: content ?? this.content, + guid: guid ?? this.guid, + thumb: thumb ?? this.thumb, + serverId: serverId ?? this.serverId, + serverName: serverName ?? this.serverName, + ); + } } diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart new file mode 100644 index 00000000..4ea6c06d --- /dev/null +++ b/lib/providers/multi_server_provider.dart @@ -0,0 +1,97 @@ +import 'package:flutter/foundation.dart'; + +import '../client/plex_client.dart'; +import '../services/data_aggregation_service.dart'; +import '../services/multi_server_manager.dart'; +import '../utils/app_logger.dart'; + +/// Provider for multi-server Plex connections +/// Manages multiple PlexClient instances and provides data aggregation +class MultiServerProvider extends ChangeNotifier { + final MultiServerManager _serverManager; + final DataAggregationService _aggregationService; + + MultiServerProvider(this._serverManager, this._aggregationService) { + // Listen to server status changes + _serverManager.statusStream.listen((_) { + notifyListeners(); + }); + } + + /// Get the multi-server manager + MultiServerManager get serverManager => _serverManager; + + /// Get the data aggregation service + DataAggregationService get aggregationService => _aggregationService; + + /// Get client for specific server + PlexClient? getClientForServer(String serverId) { + return _serverManager.getClient(serverId); + } + + /// Get all online server IDs + List get onlineServerIds => _serverManager.onlineServerIds; + + /// Get all server IDs + List get serverIds => _serverManager.serverIds; + + /// Check if a server is online + bool isServerOnline(String serverId) { + return _serverManager.isServerOnline(serverId); + } + + /// Get number of online servers + int get onlineServerCount => _serverManager.onlineServerIds.length; + + /// Get number of total servers + int get totalServerCount => _serverManager.serverIds.length; + + /// Check if any servers are connected + bool get hasConnectedServers => onlineServerCount > 0; + + /// Backward compatibility: Get first available client + /// This allows existing code to work during migration + /// Prefer using getClientForServer() for new code + @Deprecated('Use getClientForServer() or aggregationService instead') + PlexClient? get client { + final onlineClients = _serverManager.onlineClients; + if (onlineClients.isEmpty) { + return null; + } + // Return first online client for backward compatibility + return onlineClients.values.first; + } + + /// Update token for a specific server + void updateTokenForServer(String serverId, String newToken) { + final client = _serverManager.getClient(serverId); + if (client != null) { + client.updateToken(newToken); + appLogger.d('MultiServerProvider: Token updated for server $serverId'); + notifyListeners(); + } else { + appLogger.w( + 'MultiServerProvider: Cannot update token - server $serverId not found', + ); + } + } + + /// Clear all server connections + void clearAllConnections() { + _serverManager.disconnectAll(); + appLogger.d('MultiServerProvider: All connections cleared'); + notifyListeners(); + } + + /// Check server health for all connected servers + Future checkServerHealth() async { + await _serverManager.checkServerHealth(); + // notifyListeners() will be called automatically via status stream + } + + @override + void dispose() { + _serverManager.dispose(); + super.dispose(); + } +} diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 9f8877db..cafb409c 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -153,7 +153,16 @@ class PlaybackStateProvider with ChangeNotifier { ); if (response != null && response.items != null) { - _loadedItems = response.items!; + // Preserve serverId from existing items + final serverId = _loadedItems.isNotEmpty ? _loadedItems.first.serverId : null; + final serverName = _loadedItems.isNotEmpty ? _loadedItems.first.serverName : null; + + _loadedItems = response.items!.map((item) { + return item.copyWith( + serverId: serverId, + serverName: serverName, + ); + }).toList(); // Use size or items length as fallback if totalCount is null _playQueueTotalCount = response.playQueueTotalCount ?? @@ -245,7 +254,16 @@ class PlaybackStateProvider with ChangeNotifier { if (response != null && response.items != null && response.items!.isNotEmpty) { - _loadedItems = response.items!; + // Preserve serverId when looping + final serverId = _loadedItems.isNotEmpty ? _loadedItems.first.serverId : null; + final serverName = _loadedItems.isNotEmpty ? _loadedItems.first.serverName : null; + + _loadedItems = response.items!.map((item) { + return item.copyWith( + serverId: serverId, + serverName: serverName, + ); + }).toList(); final firstItem = _loadedItems.first; // Don't update _currentPlayQueueItemID here - let setCurrentItem do it when playback starts return firstItem; diff --git a/lib/providers/server_state_provider.dart b/lib/providers/server_state_provider.dart new file mode 100644 index 00000000..ace1e5ce --- /dev/null +++ b/lib/providers/server_state_provider.dart @@ -0,0 +1,76 @@ +import 'package:flutter/foundation.dart'; + +/// Provider for tracking server-specific UI state +/// Manages which server is currently in context for detail views +class ServerStateProvider extends ChangeNotifier { + String? _currentServerId; + final Map _serverConnectionStates = {}; + final Map _serverErrors = {}; + + /// Get the currently selected server ID (for detail views) + String? get currentServerId => _currentServerId; + + /// Set the current server context (e.g., when viewing a library from a specific server) + void setCurrentServer(String? serverId) { + if (_currentServerId != serverId) { + _currentServerId = serverId; + notifyListeners(); + } + } + + /// Clear the current server selection + void clearCurrentServer() { + if (_currentServerId != null) { + _currentServerId = null; + notifyListeners(); + } + } + + /// Get connection state for a server + bool isServerConnected(String serverId) { + return _serverConnectionStates[serverId] ?? false; + } + + /// Update connection state for a server + void updateServerConnectionState(String serverId, bool isConnected) { + if (_serverConnectionStates[serverId] != isConnected) { + _serverConnectionStates[serverId] = isConnected; + notifyListeners(); + } + } + + /// Get error message for a server (null if no error) + String? getServerError(String serverId) { + return _serverErrors[serverId]; + } + + /// Set error for a server + void setServerError(String serverId, String? error) { + _serverErrors[serverId] = error; + notifyListeners(); + } + + /// Clear error for a server + void clearServerError(String serverId) { + if (_serverErrors.containsKey(serverId)) { + _serverErrors.remove(serverId); + notifyListeners(); + } + } + + /// Clear all server errors + void clearAllServerErrors() { + if (_serverErrors.isNotEmpty) { + _serverErrors.clear(); + notifyListeners(); + } + } + + /// Reset all state + void reset() { + _currentServerId = null; + _serverConnectionStates.clear(); + _serverErrors.clear(); + notifyListeners(); + } +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 93c67716..9d282270 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -1,12 +1,18 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; +import '../services/server_registry.dart'; +import '../services/multi_server_manager.dart'; +import '../providers/multi_server_provider.dart'; +import '../providers/plex_client_provider.dart'; import '../i18n/strings.g.dart'; -import 'server_selection_screen.dart'; +import '../utils/app_logger.dart'; +import 'main_screen.dart'; class AuthScreen extends StatefulWidget { const AuthScreen({super.key}); @@ -33,6 +39,74 @@ class _AuthScreenState extends State { _authService = await PlexAuthService.create(); } + /// Connect to all available servers and navigate to main screen + Future _connectToAllServersAndNavigate(String plexToken) async { + if (!mounted) return; + + setState(() { + _isAuthenticating = true; + _errorMessage = null; + }); + + try { + // Fetch all servers for this user + final servers = await _authService.fetchServers(plexToken); + + if (servers.isEmpty) { + setState(() { + _isAuthenticating = false; + _errorMessage = t.serverSelection.noServersFound; + }); + return; + } + + // Save all servers to registry and enable them + final storage = await StorageService.getInstance(); + final registry = ServerRegistry(storage); + await registry.saveServers(servers); + + // Enable all servers + final serverIds = servers.map((s) => s.clientIdentifier).toSet(); + await registry.saveEnabledServerIds(serverIds); + + // Connect to all servers + if (!mounted) return; + final multiServerProvider = context.read(); + final connectedCount = await multiServerProvider.serverManager.connectToAllServers(servers); + + if (connectedCount == 0) { + setState(() { + _isAuthenticating = false; + _errorMessage = t.serverSelection.allServerConnectionsFailed; + }); + return; + } + + // Get the first connected client for backward compatibility + if (!mounted) return; + final firstClient = multiServerProvider.serverManager.onlineClients.values.first; + + // Set it as the legacy client + final plexClientProvider = context.read(); + plexClientProvider.setClient(firstClient); + + // Navigate to main screen + if (!mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => MainScreen(client: firstClient), + ), + ); + } catch (e) { + appLogger.e('Failed to connect to servers', error: e); + setState(() { + _isAuthenticating = false; + _errorMessage = t.serverSelection.failedToLoadServers(error: e); + }); + } + } + Future _startAuthentication() async { setState(() { _isAuthenticating = true; @@ -100,23 +174,16 @@ class _AuthScreenState extends State { final storage = await StorageService.getInstance(); await storage.savePlexToken(token); - // Navigate to server selection - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => ServerSelectionScreen( - authService: _authService, - plexToken: token, - ), - ), - ); - } // Clear QR URL after successful auth setState(() { _qrAuthUrl = null; _useQrFlow = false; }); + + // Connect to all servers and navigate to main screen + if (mounted) { + await _connectToAllServersAndNavigate(token); + } } catch (e) { setState(() { _isAuthenticating = false; @@ -197,17 +264,10 @@ class _AuthScreenState extends State { final storage = await StorageService.getInstance(); await storage.savePlexToken(token); - // Close dialog and navigate + // Close dialog and connect to all servers if (mounted) { navigator.pop(); - navigator.pushReplacement( - MaterialPageRoute( - builder: (context) => ServerSelectionScreen( - authService: _authService, - plexToken: token, - ), - ), - ); + await _connectToAllServersAndNavigate(token); } } catch (e) { setDialogState(() { diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 7ae55205..4fc3f7de 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/collection_playlist_play_helper.dart'; +import '../utils/app_logger.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; @@ -17,7 +21,7 @@ abstract class BaseMediaListDetailScreen String? errorMessage; @override - PlexClient get client => context.clientSafe; + PlexClient get client => _getClientForMediaItem(); /// The media item being displayed (collection or playlist) dynamic get mediaItem; @@ -28,6 +32,41 @@ abstract class BaseMediaListDetailScreen /// Message to show when list is empty String get emptyMessage; + /// Get the correct PlexClient for this media item's server + PlexClient _getClientForMediaItem() { + // Try to get serverId from the media item + String? serverId; + + // Check if mediaItem has serverId property + if (mediaItem is PlexMetadata) { + serverId = (mediaItem as PlexMetadata).serverId; + } else if (mediaItem != null && + mediaItem.runtimeType.toString().contains('PlexPlaylist')) { + // For playlists, use reflection-like access + try { + final dynamic item = mediaItem; + serverId = item.serverId as String?; + } catch (_) { + // Ignore if serverId is not available + } + } + + if (serverId == null) { + appLogger.w('Media item has no serverId, using legacy client'); + return context.read().client!; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client!; + } + + return client; + } + @override void initState() { super.initState(); @@ -54,9 +93,7 @@ abstract class BaseMediaListDetailScreen return; } - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; + final client = _getClientForMediaItem(); await playCollectionOrPlaylist( context: context, diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 5cd38b3d..c4330c9c 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/app_logger.dart'; import '../widgets/media_card.dart'; @@ -32,6 +35,25 @@ class _CollectionDetailScreenState @override String get emptyMessage => t.collections.empty; + /// Get the correct PlexClient for this collection's server + PlexClient? _getClientForCollection() { + final serverId = widget.collection.serverId; + if (serverId == null) { + appLogger.w('Collection ${widget.collection.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + @override Future loadItems() async { if (mounted) { @@ -47,9 +69,19 @@ class _CollectionDetailScreenState widget.collection.ratingKey, ); + // Tag items with server info for correct client resolution + final taggedItems = newItems + .map( + (item) => item.copyWith( + serverId: widget.collection.serverId, + serverName: widget.collection.serverName, + ), + ) + .toList(); + if (mounted) { setState(() { - items = newItems; + items = taggedItems; isLoading = false; }); } @@ -97,8 +129,7 @@ class _CollectionDetailScreenState if (!mounted) return; try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForCollection(); if (client == null) return; final success = await client.deleteCollection( diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 4e0789f9..25820746 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -6,6 +6,7 @@ import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_hub.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../services/storage_service.dart'; import '../services/plex_auth_service.dart'; import '../widgets/media_card.dart'; @@ -13,8 +14,8 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/user_avatar_widget.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/hub_section.dart'; +import '../widgets/server_badge.dart'; import 'profile_switch_screen.dart'; -import 'server_selection_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; import '../mixins/refreshable.dart'; @@ -40,7 +41,7 @@ class _DiscoverScreenState extends State static const Duration _heroAutoScrollDuration = Duration(seconds: 8); @override - PlexClient get client => context.clientSafe; + PlexClient get client => _getClientForItem(null); List _onDeck = []; List _hubs = []; @@ -53,6 +54,26 @@ class _DiscoverScreenState extends State late AnimationController _indicatorAnimationController; bool _isAutoScrollPaused = false; + /// Get the correct PlexClient for an item's server + /// If item is null, returns legacy client for backward compatibility + PlexClient _getClientForItem(PlexMetadata? item) { + final serverId = item?.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + return context.read().client!; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client!; + } + + return client; + } + @override void initState() { super.initState(); @@ -160,61 +181,51 @@ class _DiscoverScreenState extends State } Future _loadContent() async { - appLogger.d('Loading discover content'); + appLogger.d('Loading discover content from all servers'); setState(() { _isLoading = true; _errorMessage = null; }); try { - appLogger.d('Fetching onDeck and hubs from Plex'); - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); + appLogger.d('Fetching onDeck and hubs from all Plex servers'); + final multiServerProvider = Provider.of( + context, + listen: false, + ); + + if (!multiServerProvider.hasConnectedServers) { + throw Exception('No servers available'); } - final onDeck = await client.getOnDeck(); + // Fetch on deck and hubs from all servers in parallel for optimal performance + final results = await Future.wait([ + multiServerProvider.aggregationService.getOnDeckFromAllServers( + limit: 20, + ), + multiServerProvider.aggregationService.getHubsFromAllServers(), + ]); - // Load hubs from all libraries - final libraries = await client.getLibraries(); - final allHubs = []; + final onDeck = results[0] as List; + final allHubs = results[1] as List; - for (final library in libraries) { - // Skip libraries that are not movie/show or are hidden - if (library.type != 'movie' && library.type != 'show') continue; - if (library.hidden != 0) continue; - - try { - final libraryHubs = await client.getLibraryHubs( - library.key, - limit: 12, - ); - // Filter out duplicate hubs that we already fetch separately - final filteredHubs = libraryHubs.where((hub) { - final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; - final title = hub.title.toLowerCase(); - // Skip "Continue Watching" and "On Deck" hubs (we handle these separately) - return !hubId.contains('ondeck') && - !hubId.contains('continue') && - !title.contains('continue watching') && - !title.contains('on deck'); - }).toList(); - allHubs.addAll(filteredHubs); - } catch (e) { - appLogger.w( - 'Failed to load hubs for library ${library.title}', - error: e, - ); - } - } + // Filter out duplicate hubs that we already fetch separately + final filteredHubs = allHubs.where((hub) { + final hubId = hub.hubIdentifier?.toLowerCase() ?? ''; + final title = hub.title.toLowerCase(); + // Skip "Continue Watching" and "On Deck" hubs (we handle these separately) + return !hubId.contains('ondeck') && + !hubId.contains('continue') && + !title.contains('continue watching') && + !title.contains('on deck'); + }).toList(); appLogger.d( - 'Received ${onDeck.length} on deck items and ${allHubs.length} hubs', + 'Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers', ); setState(() { _onDeck = onDeck; - _hubs = allHubs; + _hubs = filteredHubs; _isLoading = false; // Reset hero index to avoid sync issues @@ -239,17 +250,17 @@ class _DiscoverScreenState extends State /// Refresh only the Continue Watching section in the background /// This is called when returning to the home screen to avoid blocking UI Future _refreshContinueWatching() async { - appLogger.d('Refreshing Continue Watching in background'); + appLogger.d('Refreshing Continue Watching in background from all servers'); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - appLogger.w('No client available for background refresh'); + final multiServerProvider = context.read(); + if (!multiServerProvider.hasConnectedServers) { + appLogger.w('No servers available for background refresh'); return; } - final onDeck = await client.getOnDeck(); + final onDeck = await multiServerProvider.aggregationService + .getOnDeckFromAllServers(limit: 20); if (mounted) { setState(() { @@ -406,43 +417,6 @@ class _DiscoverScreenState extends State } } - Future _handleSwitchServer() async { - final storage = await StorageService.getInstance(); - final plexToken = storage.getPlexToken(); - - if (plexToken == null) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(t.messages.noPlexToken))); - } - return; - } - - try { - final authService = await PlexAuthService.create(); - - // Navigate to server selection screen - if (mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ServerSelectionScreen( - authService: authService, - plexToken: plexToken, - ), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), - ); - } - } - } - Future _handleLogout() async { final confirm = await showDialog( context: context, @@ -523,8 +497,6 @@ class _DiscoverScreenState extends State onSelected: (value) { if (value == 'switch_profile') { _handleSwitchProfile(context); - } else if (value == 'switch_server') { - _handleSwitchServer(); } else if (value == 'logout') { _handleLogout(); } @@ -542,16 +514,6 @@ class _DiscoverScreenState extends State ], ), ), - PopupMenuItem( - value: 'switch_server', - child: Row( - children: [ - Icon(Icons.swap_horiz), - SizedBox(width: 8), - Text(t.discover.switchServer), - ], - ), - ), PopupMenuItem( value: 'logout', child: Row( @@ -818,10 +780,6 @@ class _DiscoverScreenState extends State hint: t.accessibility.tapToPlay, child: GestureDetector( onTap: () { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); navigateToVideoPlayer(context, metadata: heroItem); }, @@ -865,16 +823,9 @@ class _DiscoverScreenState extends State child: Opacity(opacity: value, child: child), ); }, - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ); - } + child: Builder( + builder: (context) { + final client = _getClientForItem(heroItem); return CachedNetworkImage( imageUrl: client.getThumbnailUrl( heroItem.art ?? heroItem.grandparentArt, @@ -938,12 +889,9 @@ class _DiscoverScreenState extends State SizedBox( height: 120, width: 400, - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return Container(); - } + child: Builder( + builder: (context) { + final client = _getClientForItem(heroItem); return CachedNetworkImage( imageUrl: client.getThumbnailUrl( heroItem.clearLogo, @@ -1143,10 +1091,6 @@ class _DiscoverScreenState extends State return InkWell( onTap: () { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - appLogger.d('Playing: ${heroItem.title}'); navigateToVideoPlayer(context, metadata: heroItem); }, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index e36ac821..749efad7 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -4,6 +4,8 @@ import '../client/plex_client.dart'; import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; @@ -25,7 +27,7 @@ class HubDetailScreen extends StatefulWidget { } class _HubDetailScreenState extends State with Refreshable { - PlexClient get client => context.clientSafe; + PlexClient get client => _getClientForHub(); List _items = []; List _filteredItems = []; @@ -35,6 +37,25 @@ class _HubDetailScreenState extends State with Refreshable { bool _isLoading = false; String? _errorMessage; + /// Get the correct PlexClient for this hub's server + PlexClient _getClientForHub() { + final serverId = widget.hub.serverId; + if (serverId == null) { + appLogger.w('Hub ${widget.hub.title} has no serverId, using legacy client'); + return context.read().client!; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client!; + } + + return client; + } + @override void initState() { super.initState(); @@ -51,9 +72,7 @@ class _HubDetailScreenState extends State with Refreshable { Future _loadSorts() async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; + final client = _getClientForHub(); // Get the library key from the hub key // Hub keys can have various formats: @@ -197,18 +216,22 @@ class _HubDetailScreenState extends State with Refreshable { }); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); - } + final client = _getClientForHub(); // Fetch more items from the hub using the hubKey final response = await client.getHubContent(widget.hub.hubKey); + // Tag all items with serverId from the hub + final taggedItems = response.map((item) { + return item.copyWith( + serverId: widget.hub.serverId, + serverName: widget.hub.serverName, + ); + }).toList(); + setState(() { - _items = response; - _filteredItems = response; + _items = taggedItems; + _filteredItems = taggedItems; _isLoading = false; }); @@ -216,7 +239,7 @@ class _HubDetailScreenState extends State with Refreshable { _applySort(); appLogger.d( - 'Loaded ${response.length} items for hub: ${widget.hub.title}', + 'Loaded ${taggedItems.length} items for hub: ${widget.hub.title}', ); } catch (e) { appLogger.e('Failed to load hub content', error: e); diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 513d3c17..242d2873 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -5,11 +5,14 @@ import '../client/plex_client.dart'; import '../models/plex_library.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; +import '../providers/plex_client_provider.dart'; import '../providers/hidden_libraries_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/context_menu_wrapper.dart'; +import '../widgets/server_badge.dart'; import '../services/storage_service.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; @@ -31,10 +34,37 @@ class LibrariesScreen extends StatefulWidget { class _LibrariesScreenState extends State with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { @override - PlexClient get client => context.clientSafe; + PlexClient get client => _getClientForLibrary(null); late TabController _tabController; + /// Get the correct PlexClient for a library's server + /// Returns legacy client if libraryKey is null or library not found + PlexClient _getClientForLibrary(String? libraryGlobalKey) { + if (libraryGlobalKey == null) { + return context.read().client!; + } + + // Find the library to get its serverId + final library = _allLibraries.where((lib) => lib.globalKey == libraryGlobalKey).firstOrNull; + final serverId = library?.serverId; + + if (serverId == null) { + appLogger.w('Library $libraryGlobalKey has no serverId, using legacy client'); + return context.read().client!; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client!; + } + + return client; + } + // GlobalKeys for tabs to enable refresh final _recommendedTabKey = GlobalKey>(); final _browseTabKey = GlobalKey>(); @@ -44,7 +74,7 @@ class _LibrariesScreenState extends State List _allLibraries = []; // All libraries from API (unfiltered) bool _isLoadingLibraries = true; String? _errorMessage; - String? _selectedLibraryKey; + String? _selectedLibraryGlobalKey; bool _isInitialLoad = true; Map _selectedFilters = {}; @@ -67,9 +97,9 @@ class _LibrariesScreenState extends State void _onTabChanged() { // Save tab index when changed - if (_selectedLibraryKey != null && !_tabController.indexIsChanging) { + if (_selectedLibraryGlobalKey != null && !_tabController.indexIsChanging) { StorageService.getInstance().then((storage) { - storage.saveLibraryTab(_selectedLibraryKey!, _tabController.index); + storage.saveLibraryTab(_selectedLibraryGlobalKey!, _tabController.index); }); } // Rebuild to update chip selection state @@ -98,9 +128,21 @@ class _LibrariesScreenState extends State return mapUnexpectedErrorToMessage(error, context: context); } + /// Check if libraries come from multiple servers + bool get _hasMultipleServers { + final uniqueServerIds = _allLibraries + .where((lib) => lib.serverId != null) + .map((lib) => lib.serverId) + .toSet(); + return uniqueServerIds.length > 1; + } + Future _loadLibraries() async { // Extract context dependencies before async gap - final clientProvider = context.plexClient; + final multiServerProvider = Provider.of( + context, + listen: false, + ); final hiddenLibrariesProvider = Provider.of( context, listen: false, @@ -112,13 +154,15 @@ class _LibrariesScreenState extends State }); try { - final client = clientProvider.client; - if (client == null) { + // Check if we have any connected servers + if (!multiServerProvider.hasConnectedServers) { throw Exception(t.errors.noClientAvailable); } final storage = await StorageService.getInstance(); - final allLibraries = await client.getLibraries(); + + // Fetch libraries from all servers + final allLibraries = await multiServerProvider.aggregationService.getLibrariesFromAllServers(); // Filter out music libraries (type: 'artist') since music playback is not yet supported // Only show movie and TV show libraries @@ -147,36 +191,36 @@ class _LibrariesScreenState extends State .toList(); // Load saved preferences - final savedLibraryKey = storage.getSelectedLibraryKey(); + final savedLibraryKey = storage.getSelectedLibraryKey(); - // Find the library by key in visible libraries - String? libraryKeyToLoad; - if (savedLibraryKey != null) { - // Check if saved library exists and is visible - final libraryExists = visibleLibraries.any( - (lib) => lib.key == savedLibraryKey, - ); - if (libraryExists) { - libraryKeyToLoad = savedLibraryKey; - } - } - - // Fallback to first visible library if saved key not found - if (libraryKeyToLoad == null && visibleLibraries.isNotEmpty) { - libraryKeyToLoad = visibleLibraries.first.key; - } - - if (libraryKeyToLoad != null && mounted) { - final savedFilters = storage.getLibraryFilters( - sectionId: libraryKeyToLoad, - ); - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } - _loadLibraryContent(libraryKeyToLoad); + // Find the library by key in visible libraries + String? libraryGlobalKeyToLoad; + if (savedLibraryKey != null) { + // Check if saved library exists and is visible + final libraryExists = visibleLibraries.any( + (lib) => lib.globalKey == savedLibraryKey, + ); + if (libraryExists) { + libraryGlobalKeyToLoad = savedLibraryKey; } } - } catch (e) { + + // Fallback to first visible library if saved key not found + if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) { + libraryGlobalKeyToLoad = visibleLibraries.first.globalKey; + } + + if (libraryGlobalKeyToLoad != null && mounted) { + final savedFilters = storage.getLibraryFilters( + sectionId: libraryGlobalKeyToLoad, + ); + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } + _loadLibraryContent(libraryGlobalKeyToLoad); + } + } + } catch (e) { _updateState(() { _errorMessage = _getErrorMessage(e, 'libraries'); _isLoadingLibraries = false; @@ -193,7 +237,7 @@ class _LibrariesScreenState extends State } // Create a map for quick lookup - final libraryMap = {for (var lib in libraries) lib.key: lib}; + final libraryMap = {for (var lib in libraries) lib.globalKey: lib}; // Build ordered list based on saved order final orderedLibraries = []; @@ -209,7 +253,7 @@ class _LibrariesScreenState extends State // Add any new libraries that weren't in the saved order for (final library in libraries) { - if (!addedKeys.contains(library.key)) { + if (!addedKeys.contains(library.globalKey)) { orderedLibraries.add(library); } } @@ -219,11 +263,11 @@ class _LibrariesScreenState extends State Future _saveLibraryOrder() async { final storage = await StorageService.getInstance(); - final libraryKeys = _allLibraries.map((lib) => lib.key).toList(); + final libraryKeys = _allLibraries.map((lib) => lib.globalKey).toList(); await storage.saveLibraryOrder(libraryKeys); } - Future _loadLibraryContent(String libraryKey) async { + Future _loadLibraryContent(String libraryGlobalKey) async { // Compute visible libraries based on current provider state final hiddenLibrariesProvider = Provider.of( context, @@ -236,25 +280,20 @@ class _LibrariesScreenState extends State // Find the library by key final libraryIndex = visibleLibraries.indexWhere( - (lib) => lib.key == libraryKey, + (lib) => lib.globalKey == libraryGlobalKey, ); if (libraryIndex == -1) return; // Library not found or hidden - final isChangingLibrary = - !_isInitialLoad && _selectedLibraryKey != libraryKey; + final library = visibleLibraries[libraryIndex]; - // Extract context dependencies before async operations - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - _updateState(() { - _errorMessage = t.errors.noClientAvailable; - }); - return; - } + final isChangingLibrary = + !_isInitialLoad && _selectedLibraryGlobalKey != libraryGlobalKey; + + // Get the correct client for this library's server + final client = _getClientForLibrary(libraryGlobalKey); _updateState(() { - _selectedLibraryKey = libraryKey; + _selectedLibraryGlobalKey = libraryGlobalKey; _errorMessage = null; // Only clear filters when explicitly changing library (not on initial load) if (isChangingLibrary) { @@ -269,10 +308,10 @@ class _LibrariesScreenState extends State // Save selected library key and restore saved tab final storage = await StorageService.getInstance(); - await storage.saveSelectedLibraryKey(libraryKey); + await storage.saveSelectedLibraryKey(libraryGlobalKey); // Restore saved tab index for this library - final savedTabIndex = storage.getLibraryTab(libraryKey); + final savedTabIndex = storage.getLibraryTab(libraryGlobalKey); if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) { _updateState(() { _tabController.index = savedTabIndex; @@ -281,7 +320,7 @@ class _LibrariesScreenState extends State // Clear filters in storage when changing library if (isChangingLibrary) { - await storage.saveLibraryFilters({}, sectionId: libraryKey); + await storage.saveLibraryFilters({}, sectionId: libraryGlobalKey); } // Cancel any existing requests @@ -298,13 +337,13 @@ class _LibrariesScreenState extends State try { // Load sort options for the new library - await _loadSortOptions(libraryKey); + await _loadSortOptions(library); final filtersWithSort = _buildFiltersWithSort(); // Load pages sequentially await _loadAllPagesSequentially( - libraryKey, + library, filtersWithSort, currentRequestId, client, @@ -323,7 +362,7 @@ class _LibrariesScreenState extends State /// Load all pages sequentially until all items are fetched Future _loadAllPagesSequentially( - String libraryKey, + PlexLibrary library, Map filtersWithSort, int requestId, PlexClient client, @@ -331,22 +370,32 @@ class _LibrariesScreenState extends State while (_hasMoreItems && requestId == _requestId) { try { final items = await client.getLibraryContent( - libraryKey, + library.key, start: _currentPage * _pageSize, size: _pageSize, filters: filtersWithSort, cancelToken: _cancelToken, ); + // Tag items with server info for multi-server support + final taggedItems = items + .map( + (item) => item.copyWith( + serverId: library.serverId, + serverName: library.serverName, + ), + ) + .toList(); + // Check if request is still valid if (requestId != _requestId) { return; // Request was superseded } _updateState(() { - _items.addAll(items); + _items.addAll(taggedItems); _currentPage++; - _hasMoreItems = items.length >= _pageSize; + _hasMoreItems = taggedItems.length >= _pageSize; }); } catch (e) { // Check if it's a cancellation @@ -363,19 +412,15 @@ class _LibrariesScreenState extends State } } - Future _loadSortOptions(String libraryKey) async { + Future _loadSortOptions(PlexLibrary library) async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } + final client = _getClientForLibrary(library.globalKey); - final sortOptions = await client.getLibrarySorts(libraryKey); + final sortOptions = await client.getLibrarySorts(library.key); // Load saved sort preference for this library final storage = await StorageService.getInstance(); - final savedSortData = storage.getLibrarySort(libraryKey); + final savedSortData = storage.getLibrarySort(library.globalKey); // Find the saved sort in the options PlexSort? savedSort; @@ -466,7 +511,7 @@ class _LibrariesScreenState extends State void fullRefresh() { appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); // Reload libraries and clear any selected library/filters - _selectedLibraryKey = null; + _selectedLibraryGlobalKey = null; _selectedFilters.clear(); _items.clear(); _loadLibraries(); @@ -485,7 +530,7 @@ class _LibrariesScreenState extends State await hiddenLibrariesProvider.unhideLibrary(library.key); } else { // Check if we're hiding the currently selected library - final isCurrentlySelected = _selectedLibraryKey == library.key; + final isCurrentlySelected = _selectedLibraryGlobalKey == library.globalKey; await hiddenLibrariesProvider.hideLibrary(library.key); @@ -500,7 +545,7 @@ class _LibrariesScreenState extends State .toList(); if (visibleLibraries.isNotEmpty) { - _loadLibraryContent(visibleLibraries.first.key); + _loadLibraryContent(visibleLibraries.first.globalKey); } } } @@ -603,11 +648,7 @@ class _LibrariesScreenState extends State required String Function(Object error) failureMessage, }) async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception(t.errors.noClientAvailable); - } + final client = _getClientForLibrary(library.globalKey); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -684,6 +725,114 @@ class _LibrariesScreenState extends State ); } + /// Build grouped dropdown menu items with server sections + List> _buildGroupedLibraryMenuItems( + List visibleLibraries, + ) { + final List> menuItems = []; + + if (!_hasMultipleServers) { + // Single server: flat list + return visibleLibraries.map((library) { + final isSelected = library.globalKey == _selectedLibraryGlobalKey; + return PopupMenuItem( + value: library.globalKey, + child: Row( + children: [ + Icon( + _getLibraryIcon(library.type), + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + library.title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ], + ), + ); + }).toList(); + } + + // Multiple servers: group by server + final Map> groupedLibraries = {}; + for (final library in visibleLibraries) { + final serverKey = library.serverId ?? 'unknown'; + groupedLibraries.putIfAbsent(serverKey, () => []).add(library); + } + + final serverKeys = groupedLibraries.keys.toList(); + for (int i = 0; i < serverKeys.length; i++) { + final serverKey = serverKeys[i]; + final libraries = groupedLibraries[serverKey]!; + final serverName = libraries.first.serverName ?? 'Unknown Server'; + + // Add server header + menuItems.add( + PopupMenuItem( + enabled: false, + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + serverName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + + // Add libraries for this server + for (final library in libraries) { + final isSelected = library.globalKey == _selectedLibraryGlobalKey; + menuItems.add( + PopupMenuItem( + value: library.globalKey, + child: Row( + children: [ + const SizedBox(width: 12), // Indent library items + Icon( + _getLibraryIcon(library.type), + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + const SizedBox(width: 12), + Text( + library.title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ], + ), + ), + ); + } + + // Add divider between server groups (except after last) + if (i < serverKeys.length - 1) { + menuItems.add(const PopupMenuDivider()); + } + } + + return menuItems; + } + Widget _buildTabChip(String label, int index) { final isSelected = _tabController.index == index; final t = tokens(context); @@ -711,54 +860,44 @@ class _LibrariesScreenState extends State Widget _buildLibraryDropdownTitle(List visibleLibraries) { final selectedLibrary = visibleLibraries.firstWhere( - (lib) => lib.key == _selectedLibraryKey, + (lib) => lib.globalKey == _selectedLibraryGlobalKey, orElse: () => visibleLibraries.first, ); return PopupMenuButton( offset: const Offset(0, 48), tooltip: t.libraries.selectLibrary, - onSelected: (libraryKey) { - _loadLibraryContent(libraryKey); - }, - itemBuilder: (context) { - return visibleLibraries.map((library) { - final isSelected = library.key == _selectedLibraryKey; - return PopupMenuItem( - value: library.key, - child: Row( - children: [ - Icon( - _getLibraryIcon(library.type), - size: 20, - color: isSelected - ? Theme.of(context).colorScheme.primary - : null, - ), - const SizedBox(width: 12), - Text( - library.title, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected - ? Theme.of(context).colorScheme.primary - : null, - ), - ), - ], - ), - ); - }).toList(); + onSelected: (libraryGlobalKey) { + _loadLibraryContent(libraryGlobalKey); }, + itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_getLibraryIcon(selectedLibrary.type), size: 20), const SizedBox(width: 8), - Text( - selectedLibrary.title, - style: Theme.of(context).textTheme.titleLarge, - ), + if (_hasMultipleServers && selectedLibrary.serverName != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + selectedLibrary.serverName!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + ], + ) + else + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 24), ], @@ -781,7 +920,7 @@ class _LibrariesScreenState extends State body: CustomScrollView( slivers: [ DesktopSliverAppBar( - title: visibleLibraries.isNotEmpty && _selectedLibraryKey != null + title: visibleLibraries.isNotEmpty && _selectedLibraryGlobalKey != null ? _buildLibraryDropdownTitle(visibleLibraries) : Text(t.libraries.title), floating: true, @@ -850,7 +989,7 @@ class _LibrariesScreenState extends State ) else ...[ // Tab selector chips - if (_selectedLibraryKey != null) + if (_selectedLibraryGlobalKey != null) SliverToBoxAdapter( child: Container( padding: const EdgeInsets.symmetric( @@ -875,7 +1014,7 @@ class _LibrariesScreenState extends State ), // Tab content - if (_selectedLibraryKey != null) + if (_selectedLibraryGlobalKey != null) SliverFillRemaining( child: TabBarView( controller: _tabController, @@ -883,25 +1022,25 @@ class _LibrariesScreenState extends State LibraryRecommendedTab( key: _recommendedTabKey, library: _allLibraries.firstWhere( - (lib) => lib.key == _selectedLibraryKey, + (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), ), LibraryBrowseTab( key: _browseTabKey, library: _allLibraries.firstWhere( - (lib) => lib.key == _selectedLibraryKey, + (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), ), LibraryCollectionsTab( key: _collectionsTabKey, library: _allLibraries.firstWhere( - (lib) => lib.key == _selectedLibraryKey, + (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), ), LibraryPlaylistsTab( key: _playlistsTabKey, library: _allLibraries.firstWhere( - (lib) => lib.key == _selectedLibraryKey, + (lib) => lib.globalKey == _selectedLibraryGlobalKey, ), ), ], @@ -960,6 +1099,15 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { _tempLibraries = List.from(widget.allLibraries); } + /// Check if libraries come from multiple servers + bool get _hasMultipleServers { + final uniqueServerIds = _tempLibraries + .where((lib) => lib.serverId != null) + .map((lib) => lib.serverId) + .toSet(); + return uniqueServerIds.length > 1; + } + void _reorderLibraries(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { @@ -1141,7 +1289,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { Icon(_getLibraryIcon(library.type)), ], ), - title: Text(library.title), + title: Row( + children: [ + Expanded(child: Text(library.title)), + if (_hasMultipleServers && library.serverName != null) + ServerBadge(serverName: library.serverName), + ], + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index c1de6bd8..191b4361 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -7,6 +7,7 @@ import '../../models/plex_metadata.dart'; import '../../models/plex_filter.dart'; import '../../models/plex_sort.dart'; import '../../providers/plex_client_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/error_message_utils.dart'; @@ -20,6 +21,7 @@ import '../../services/settings_service.dart' show ViewMode; import '../../mixins/item_updatable.dart'; import '../../mixins/refreshable.dart'; import '../../i18n/strings.g.dart'; +import '../../utils/app_logger.dart'; /// Browse tab for library screen /// Shows library items with grouping, filtering, and sorting @@ -47,6 +49,26 @@ class _LibraryBrowseTabState extends State @override PlexClient get client => context.clientSafe; + /// Get the correct PlexClient for this library's server + PlexClient? _getClientForLibrary(BuildContext context) { + final serverId = widget.library.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Library ${widget.library.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + @override void refresh() { _loadContent(); @@ -89,7 +111,7 @@ class _LibraryBrowseTabState extends State void didUpdateWidget(LibraryBrowseTab oldWidget) { super.didUpdateWidget(oldWidget); // Reload if library changed - if (oldWidget.library.key != widget.library.key) { + if (oldWidget.library.globalKey != widget.library.globalKey) { _loadContent(); } } @@ -106,8 +128,8 @@ class _LibraryBrowseTabState extends State _cancelToken = CancelToken(); final currentRequestId = ++_requestId; - // Extract context dependencies before async gap - final clientProvider = context.plexClient; + // Extract context dependencies before async gap - use server-specific client + final client = _getClientForLibrary(context); setState(() { _isLoading = true; @@ -118,7 +140,6 @@ class _LibraryBrowseTabState extends State }); try { - final client = clientProvider.client; if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -131,10 +152,10 @@ class _LibraryBrowseTabState extends State // Load saved preferences final savedFilters = storage.getLibraryFilters( - sectionId: widget.library.key, + sectionId: widget.library.globalKey, ); - final savedSort = storage.getLibrarySort(widget.library.key); - final savedGrouping = storage.getLibraryGrouping(widget.library.key); + final savedSort = storage.getLibrarySort(widget.library.globalKey); + final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey); // Check if request was cancelled if (currentRequestId != _requestId) return; @@ -192,7 +213,8 @@ class _LibraryBrowseTabState extends State }); try { - final client = context.read().client; + // Use server-specific client for this library + final client = _getClientForLibrary(context); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -223,15 +245,25 @@ class _LibraryBrowseTabState extends State cancelToken: _cancelToken, ); + // Tag items with server info for multi-server support + final taggedItems = items + .map( + (item) => item.copyWith( + serverId: widget.library.serverId, + serverName: widget.library.serverName, + ), + ) + .toList(); + if (currentRequestId != _requestId) return; setState(() { if (loadMore) { - _items.addAll(items); + _items.addAll(taggedItems); } else { - _items = items; + _items = taggedItems; } - _hasMoreItems = items.length >= _pageSize; + _hasMoreItems = taggedItems.length >= _pageSize; _currentPage++; _isLoading = false; }); @@ -324,8 +356,8 @@ class _LibraryBrowseTabState extends State _selectedGrouping = value; }); - final storage = await StorageService.getInstance(); - await storage.saveLibraryGrouping(widget.library.key, value); + final storage = await StorageService.getInstance(); + await storage.saveLibraryGrouping(widget.library.globalKey, value); if (!mounted) return; @@ -357,7 +389,7 @@ class _LibraryBrowseTabState extends State final storage = await StorageService.getInstance(); await storage.saveLibraryFilters( filters, - sectionId: widget.library.key, + sectionId: widget.library.globalKey, ); _loadItems(); @@ -382,7 +414,7 @@ class _LibraryBrowseTabState extends State StorageService.getInstance().then((storage) { storage.saveLibrarySort( - widget.library.key, + widget.library.globalKey, sort.key, descending: descending, ); @@ -487,6 +519,7 @@ class _LibraryBrowseTabState extends State if (_selectedGrouping == 'folders') { return FolderTreeView( libraryKey: widget.library.key, + serverId: widget.library.serverId, onRefresh: updateItem, ); } diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index 681b8231..61639367 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../client/plex_client.dart'; import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; import '../../providers/plex_client_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; import '../../i18n/strings.g.dart'; @@ -39,6 +41,26 @@ class _LibraryCollectionsTabState extends State _loadCollections(); } + /// Get the correct PlexClient for this library's server + PlexClient? _getClientForLibrary(BuildContext context) { + final serverId = widget.library.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Library ${widget.library.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + List _collections = []; bool _isLoading = false; String? _errorMessage; @@ -69,7 +91,7 @@ class _LibraryCollectionsTabState extends State void didUpdateWidget(LibraryCollectionsTab oldWidget) { super.didUpdateWidget(oldWidget); // Reload if library changed - if (oldWidget.library.key != widget.library.key) { + if (oldWidget.library.globalKey != widget.library.globalKey) { _loadCollections(); } } @@ -81,7 +103,8 @@ class _LibraryCollectionsTabState extends State }); try { - final client = context.read().client; + // Use server-specific client for this library + final client = _getClientForLibrary(context); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -92,8 +115,17 @@ class _LibraryCollectionsTabState extends State if (!mounted) return; + final taggedCollections = collections + .map( + (item) => item.copyWith( + serverId: widget.library.serverId, + serverName: widget.library.serverName, + ), + ) + .toList(); + setState(() { - _collections = collections; + _collections = taggedCollections; _isLoading = false; }); } catch (e) { diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index 84f6b1be..bef70194 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../client/plex_client.dart'; import '../../models/plex_library.dart'; import '../../models/plex_playlist.dart'; import '../../providers/plex_client_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../providers/settings_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/library_refresh_notifier.dart'; @@ -42,6 +44,26 @@ class _LibraryPlaylistsTabState extends State _loadPlaylists(); } + /// Get the correct PlexClient for this library's server + PlexClient? _getClientForLibrary(BuildContext context) { + final serverId = widget.library.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Library ${widget.library.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + List _playlists = []; bool _isLoading = false; String? _errorMessage; @@ -70,7 +92,7 @@ class _LibraryPlaylistsTabState extends State void didUpdateWidget(LibraryPlaylistsTab oldWidget) { super.didUpdateWidget(oldWidget); // Reload if library changed - if (oldWidget.library.key != widget.library.key) { + if (oldWidget.library.globalKey != widget.library.globalKey) { _loadPlaylists(); } } @@ -82,7 +104,8 @@ class _LibraryPlaylistsTabState extends State }); try { - final client = context.read().client; + // Use server-specific client for this library + final client = _getClientForLibrary(context); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -95,8 +118,17 @@ class _LibraryPlaylistsTabState extends State if (!mounted) return; + final taggedPlaylists = playlists + .map( + (playlist) => playlist.copyWith( + serverId: widget.library.serverId, + serverName: widget.library.serverName, + ), + ) + .toList(); + setState(() { - _playlists = playlists; + _playlists = taggedPlaylists; _isLoading = false; }); } catch (e) { diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 89957997..1c26cfc0 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../client/plex_client.dart'; import '../../models/plex_library.dart'; import '../../models/plex_hub.dart'; import '../../providers/plex_client_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; import '../../widgets/hub_section.dart'; import '../../i18n/strings.g.dart'; @@ -30,6 +32,26 @@ class _LibraryRecommendedTabState extends State _loadHubs(); } + /// Get the correct PlexClient for this library's server + PlexClient? _getClientForLibrary(BuildContext context) { + final serverId = widget.library.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Library ${widget.library.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + List _hubs = []; bool _isLoading = false; String? _errorMessage; @@ -44,7 +66,7 @@ class _LibraryRecommendedTabState extends State void didUpdateWidget(LibraryRecommendedTab oldWidget) { super.didUpdateWidget(oldWidget); // Reload if library changed - if (oldWidget.library.key != widget.library.key) { + if (oldWidget.library.globalKey != widget.library.globalKey) { _loadHubs(); } } @@ -56,7 +78,8 @@ class _LibraryRecommendedTabState extends State }); try { - final client = context.read().client; + // Use server-specific client for this library + final client = _getClientForLibrary(context); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -65,8 +88,32 @@ class _LibraryRecommendedTabState extends State if (!mounted) return; + // Tag hubs and items with server info + final taggedHubs = hubs + .map( + (hub) => PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: hub.size, + more: hub.more, + items: hub.items + .map( + (item) => item.copyWith( + serverId: widget.library.serverId, + serverName: widget.library.serverName, + ), + ) + .toList(), + serverId: widget.library.serverId, + serverName: widget.library.serverName, + ), + ) + .toList(); + setState(() { - _hubs = hubs; + _hubs = taggedHubs; _isLoading = false; }); } catch (e) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index f81db457..198457bc 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3,8 +3,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import '../utils/content_rating_formatter.dart'; @@ -49,14 +51,34 @@ class _MediaDetailScreenState extends State { super.dispose(); } + /// Get the correct PlexClient for this metadata's server + PlexClient? _getClientForMetadata(BuildContext context) { + final serverId = widget.metadata.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Metadata ${widget.metadata.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + Future _loadFullMetadata() async { setState(() { _isLoadingMetadata = true; }); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) { throw Exception('No client available'); } @@ -69,9 +91,19 @@ class _MediaDetailScreenState extends State { final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; if (metadata != null) { + // Preserve serverId from original metadata + final metadataWithServerId = metadata.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); + final onDeckWithServerId = onDeckEpisode?.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); + setState(() { - _fullMetadata = metadata; - _onDeckEpisode = onDeckEpisode; + _fullMetadata = metadataWithServerId; + _onDeckEpisode = onDeckWithServerId; _isLoadingMetadata = false; }); @@ -110,15 +142,20 @@ class _MediaDetailScreenState extends State { }); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) { throw Exception('No client available'); } final seasons = await client.getChildren(widget.metadata.ratingKey); + // Preserve serverId for each season + final seasonsWithServerId = seasons.map((season) => season.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + )).toList(); setState(() { - _seasons = seasons; + _seasons = seasonsWithServerId; _isLoadingSeasons = false; }); } catch (e) { @@ -132,8 +169,8 @@ class _MediaDetailScreenState extends State { /// This preserves scroll position and only updates watch-related data Future _updateWatchState() async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) { throw Exception('No client available'); } @@ -143,15 +180,26 @@ class _MediaDetailScreenState extends State { ); if (metadata != null) { + // Preserve serverId from original metadata + final metadataWithServerId = metadata.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); + // For shows, also refetch seasons to update their watch counts List? updatedSeasons; if (metadata.type.toLowerCase() == 'show') { - updatedSeasons = await client.getChildren(widget.metadata.ratingKey); + final seasons = await client.getChildren(widget.metadata.ratingKey); + // Preserve serverId for each season + updatedSeasons = seasons.map((season) => season.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + )).toList(); } // Single setState to minimize rebuilds - scroll position is preserved by controller setState(() { - _fullMetadata = metadata; + _fullMetadata = metadataWithServerId; if (updatedSeasons != null) { _seasons = updatedSeasons; } @@ -165,9 +213,8 @@ class _MediaDetailScreenState extends State { Future _playFirstEpisode() async { try { - // Extract context dependencies before async operations - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) { throw Exception('No client available'); } @@ -208,13 +255,15 @@ class _MediaDetailScreenState extends State { // Play the first episode final firstEpisode = episodes.first; + // Preserve serverId for the episode + final episodeWithServerId = firstEpisode.copyWith( + serverId: widget.metadata.serverId, + serverName: widget.metadata.serverName, + ); if (mounted) { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; - - appLogger.d('Playing first episode: ${firstEpisode.title}'); - await navigateToVideoPlayer(context, metadata: firstEpisode); + // Client already retrieved earlier in the method + appLogger.d('Playing first episode: ${episodeWithServerId.title}'); + await navigateToVideoPlayer(context, metadata: episodeWithServerId); appLogger.d('Returned from playback, refreshing metadata'); // Refresh metadata when returning from video player _loadFullMetadata(); @@ -265,9 +314,9 @@ class _MediaDetailScreenState extends State { children: [ // Background Art if (metadata.art != null) - Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + Builder( + builder: (context) { + final client = _getClientForMetadata(context); if (client == null) { return Container( color: Theme.of( @@ -331,9 +380,9 @@ class _MediaDetailScreenState extends State { SizedBox( height: 120, width: 400, - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + child: Builder( + builder: (context) { + final client = _getClientForMetadata(context); if (client == null) { return Text( metadata.title, @@ -593,8 +642,7 @@ class _MediaDetailScreenState extends State { // Otherwise, play the first episode of the first season if (metadata.type.toLowerCase() == 'show') { if (_onDeckEpisode != null) { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(context); if (client == null) return; appLogger.d( @@ -614,8 +662,7 @@ class _MediaDetailScreenState extends State { await _playFirstEpisode(); } } else { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(context); if (client == null) return; appLogger.d('Playing: ${metadata.title}'); @@ -665,8 +712,7 @@ class _MediaDetailScreenState extends State { IconButton.filledTonal( onPressed: () async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(context); if (client == null) return; await client.markAsWatched(metadata.ratingKey); @@ -706,8 +752,7 @@ class _MediaDetailScreenState extends State { IconButton.filledTonal( onPressed: () async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(context); if (client == null) return; await client.markAsUnwatched(metadata.ratingKey); @@ -985,9 +1030,9 @@ class _MediaDetailScreenState extends State { if (season.thumb != null) ClipRRect( borderRadius: BorderRadius.circular(6), - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + child: Builder( + builder: (context) { + final client = _getClientForMetadata(context); if (client == null) { return Container( width: 80, diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index c761030f..626c9ee8 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_playlist.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/playback_state_provider.dart'; import '../utils/app_logger.dart'; @@ -35,6 +38,25 @@ class _PlaylistDetailScreenState @override String get emptyMessage => t.playlists.emptyPlaylist; + /// Get the correct PlexClient for this playlist's server + PlexClient? _getClientForPlaylist() { + final serverId = widget.playlist.serverId; + if (serverId == null) { + appLogger.w('Playlist ${widget.playlist.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + @override Future loadItems() async { if (mounted) { @@ -48,9 +70,19 @@ class _PlaylistDetailScreenState final client = this.client; final newItems = await client.getPlaylist(widget.playlist.ratingKey); + // Tag items with server info for correct client resolution + final taggedItems = newItems + .map( + (item) => item.copyWith( + serverId: widget.playlist.serverId, + serverName: widget.playlist.serverName, + ), + ) + .toList(); + if (mounted) { setState(() { - items = newItems; + items = taggedItems; isLoading = false; }); } @@ -221,8 +253,7 @@ class _PlaylistDetailScreenState if (items.isEmpty || index < 0 || index >= items.length) return; try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForPlaylist(); if (client == null) return; final selectedItem = items[index]; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 7b181dbb..b6290c12 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -8,6 +8,7 @@ import '../i18n/strings.g.dart'; import '../mixins/item_updatable.dart'; import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; @@ -15,6 +16,7 @@ import '../utils/grid_cross_axis_extent.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; +import '../widgets/server_badge.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @@ -91,13 +93,17 @@ class _SearchScreenState extends State }); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); + final multiServerProvider = Provider.of( + context, + listen: false, + ); + + if (!multiServerProvider.hasConnectedServers) { + throw Exception('No servers available'); } - final results = await client.search(query); + // Search across all connected servers + final results = await multiServerProvider.aggregationService.searchAcrossServers(query); if (mounted) { setState(() { _searchResults = results; diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index 9ffd3442..3d095217 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -4,9 +4,10 @@ import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; -import '../utils/provider_extensions.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/video_player_navigation.dart'; import '../utils/duration_formatter.dart'; +import '../utils/app_logger.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; @@ -24,17 +25,42 @@ class SeasonDetailScreen extends StatefulWidget { class _SeasonDetailScreenState extends State with ItemUpdatable { + late final PlexClient _client; + @override - PlexClient get client => context.clientSafe; + PlexClient get client => _client; List _episodes = []; bool _isLoadingEpisodes = false; bool _watchStateChanged = false; + /// Get the correct PlexClient for this season's server + PlexClient _getClientForSeason(BuildContext context) { + final serverId = widget.season.serverId; + if (serverId == null) { + appLogger.w('Season ${widget.season.title} has no serverId, using legacy client'); + return context.read().client!; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client!; + } + + return client; + } + @override void initState() { super.initState(); - _loadEpisodes(); + // Initialize the client once in initState + WidgetsBinding.instance.addPostFrameCallback((_) { + _client = _getClientForSeason(context); + _loadEpisodes(); + }); } Future _loadEpisodes() async { @@ -43,15 +69,14 @@ class _SeasonDetailScreenState extends State }); try { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) { - throw Exception('No client available'); - } - - final episodes = await client.getChildren(widget.season.ratingKey); + final episodes = await _client.getChildren(widget.season.ratingKey); + // Preserve serverId for each episode + final episodesWithServerId = episodes.map((episode) => episode.copyWith( + serverId: widget.season.serverId, + serverName: widget.season.serverName, + )).toList(); setState(() { - _episodes = episodes; + _episodes = episodesWithServerId; _isLoadingEpisodes = false; }); } catch (e) { @@ -165,21 +190,10 @@ class _SeasonDetailScreenState extends State child: AspectRatio( aspectRatio: 16 / 9, child: episode.thumb != null - ? Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Center( - child: Icon(Icons.movie, size: 40), - ), - ); - } + ? Builder( + builder: (context) { return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( + imageUrl: _client.getThumbnailUrl( episode.thumb, ), filterQuality: FilterQuality.medium, diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart deleted file mode 100644 index 2740e7a7..00000000 --- a/lib/screens/server_selection_screen.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:dio/dio.dart'; -import '../i18n/strings.g.dart'; -import '../services/plex_auth_service.dart'; -import '../services/storage_service.dart'; -import '../services/server_connection_service.dart'; -import '../widgets/server_list_tile.dart'; -import '../widgets/desktop_app_bar.dart'; -import '../utils/app_logger.dart'; -import '../utils/provider_extensions.dart'; -import '../utils/error_message_utils.dart'; -import 'main_screen.dart'; - -class ServerSelectionScreen extends StatefulWidget { - final PlexAuthService authService; - final String plexToken; - - const ServerSelectionScreen({ - super.key, - required this.authService, - required this.plexToken, - }); - - @override - State createState() => _ServerSelectionScreenState(); -} - -class _ServerSelectionScreenState extends State { - List? _servers; - bool _isLoading = true; - String? _errorMessage; - String? _currentServerUrl; - List>? _debugServerData; - - @override - void initState() { - super.initState(); - _loadCurrentServerUrl(); - _loadServers(); - } - - Future _loadCurrentServerUrl() async { - try { - final storage = await StorageService.getInstance(); - setState(() { - _currentServerUrl = storage.getServerUrl(); - }); - } catch (e) { - appLogger.w('Failed to load current server URL', error: e); - } - } - - Future _loadServers() async { - try { - final servers = await widget.authService.fetchServers(widget.plexToken); - setState(() { - _servers = servers; - _isLoading = false; - _debugServerData = null; // Clear any previous debug data - }); - } catch (e) { - setState(() { - _errorMessage = _getErrorMessage(e); - _isLoading = false; - // Store debug data if it's a parsing exception - if (e is ServerParsingException) { - _debugServerData = e.invalidServerData; - } else { - _debugServerData = null; - } - }); - appLogger.e('Failed to load servers', error: e); - } - } - - String _getErrorMessage(dynamic error) { - if (error is DioException) { - return mapDioErrorToMessage( - error, - context: t.serverSelection.noServersFound, - ); - } - if (error is ServerParsingException) { - return t.serverSelection.malformedServerData( - count: error.invalidServerData.length, - ); - } else if (error is FormatException) { - // Handle JSON parsing errors with more user-friendly messages - if (error.message.contains('Invalid server data')) { - return t.serverSelection.incompleteServerInfo; - } else if (error.message.contains('Invalid connection data')) { - return t.serverSelection.incompleteConnectionInfo; - } - return t.serverSelection.malformedServerInfo(message: error.message); - } else if (error.toString().contains('SocketException') || - error.toString().contains('TimeoutException')) { - return t.serverSelection.networkConnectionFailed; - } else if (error.toString().contains('401') || - error.toString().contains('Unauthorized')) { - return t.serverSelection.authenticationFailed; - } else if (error.toString().contains('404') || - error.toString().contains('Not Found')) { - return t.serverSelection.plexServiceUnavailable; - } - - return t.serverSelection.failedToLoadServers(error: error.toString()); - } - - Future _copyDebugDataToClipboard() async { - if (_debugServerData == null) return; - - final jsonString = const JsonEncoder.withIndent( - ' ', - ).convert(_debugServerData); - await Clipboard.setData(ClipboardData(text: jsonString)); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.serverSelection.serverDebugCopied)), - ); - } - } - - Future _selectServer(PlexServer server) async { - // Show loading dialog - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text(t.serverSelection.connectingToServer), - ], - ), - ), - ); - } - - try { - // Get client identifier and storage - final storage = await StorageService.getInstance(); - final clientId = - storage.getClientIdentifier() ?? widget.authService.clientIdentifier; - - // Get current profile context to maintain profile across server switch - final currentUserUUID = storage.getCurrentUserUUID(); - - PlexServer serverWithCorrectToken = server; - - // If we have a current profile, get a profile-specific token for this server - if (currentUserUUID != null) { - try { - // Switch to the current profile on the new server to get the correct token - final switchResponse = await widget.authService.switchToUser( - currentUserUUID, - widget.plexToken, - ); - - // Get servers with the profile's Plex.tv token to get profile-specific server tokens - final servers = await widget.authService.fetchServers( - switchResponse.authToken, - ); - - // Find the matching server with the profile-specific token - final matchingServer = servers.firstWhere( - (s) => - s.name == server.name || - s.clientIdentifier == server.clientIdentifier, - orElse: () => server, // Fallback to original server - ); - - serverWithCorrectToken = matchingServer; - appLogger.d( - 'Got profile-specific token for server ${server.name} and user UUID $currentUserUUID', - ); - } catch (e) { - appLogger.w( - 'Failed to get profile-specific token, using default server token', - error: e, - ); - // Continue with original server token as fallback - } - } - - // Connect using the server with the correct token - final result = await ServerConnectionService.connectToServer( - serverWithCorrectToken, - clientIdentifier: clientId, - plexToken: widget.plexToken, - ); - - // Close loading dialog - if (mounted) { - Navigator.pop(context); - } - - // Handle result - if (result.isSuccess) { - // Set client in provider before navigation (same pattern as auto-login) - if (mounted) { - context.plexClient.setClient(result.client!); - - // Navigate to main app and clear navigation stack - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) => MainScreen(client: result.client!), - ), - (route) => false, // Remove all routes - ); - } - } else { - // Show error - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(result.error ?? t.errors.connectionFailedGeneric), - ), - ); - } - } - } catch (e) { - // Close loading dialog on error - if (mounted) { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))), - ); - } - appLogger.e('Server selection failed', error: e); - } - } - - bool _isCurrentServer(PlexServer server) { - if (_currentServerUrl == null) return false; - - // Check all server connections to see if any match the current server URL - for (final connection in server.connections) { - if (connection.uri == _currentServerUrl) { - return true; - } - } - return false; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.screens.selectServer)), - SliverFillRemaining( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _errorMessage != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - _errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: _loadServers, - child: Text(t.common.retry), - ), - if (_debugServerData != null) ...[ - const SizedBox(width: 16), - OutlinedButton.icon( - onPressed: _copyDebugDataToClipboard, - icon: const Icon(Icons.copy), - label: Text(t.serverSelection.copyDebugData), - ), - ], - ], - ), - if (_debugServerData != null) ...[ - const SizedBox(height: 12), - Text( - 'Debug data available for ${_debugServerData!.length} server(s)', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ], - ), - ) - : _servers == null || _servers!.isEmpty - ? Center(child: Text(t.serverSelection.noServersFound)) - : ListView.builder( - itemCount: _servers!.length, - padding: const EdgeInsets.all(16), - itemBuilder: (context, index) { - final server = _servers![index]; - final isCurrentServer = _isCurrentServer(server); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Card( - child: ServerListTile( - server: server, - isCurrentServer: isCurrentServer, - onTap: () => _selectServer(server), - ), - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 0b372c31..7b32b2e7 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -14,6 +14,7 @@ import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/playback_state_provider.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; @@ -55,7 +56,7 @@ class VideoPlayerScreenState extends State PlexMetadata? _previousEpisode; bool _isLoadingNext = false; bool _showPlayNextDialog = false; - PlexClientProvider? _cachedClientProvider; + PlexClient? _cachedClient; bool _isPhone = false; List _availableVersions = []; StreamSubscription? _logSubscription; @@ -71,6 +72,26 @@ class VideoPlayerScreenState extends State // App lifecycle state tracking bool _wasPlayingBeforeInactive = false; + /// Get the correct PlexClient for this metadata's server + PlexClient? _getClientForMetadata(BuildContext context) { + final serverId = widget.metadata.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Metadata ${widget.metadata.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode = 0; bool _isPinching = false; // Track if a pinch gesture is occurring @@ -128,12 +149,12 @@ class VideoPlayerScreenState extends State void didChangeDependencies() { super.didChangeDependencies(); - // Cache provider reference for safe access in dispose() + // Cache server-specific client for safe access in dispose() and during playback try { - _cachedClientProvider = context.plexClient; + _cachedClient = _getClientForMetadata(context); } catch (e) { - appLogger.w('Failed to cache PlexClientProvider', error: e); - _cachedClientProvider = null; + appLogger.w('Failed to cache PlexClient', error: e); + _cachedClient = null; } // Cache device type for safe access in dispose() @@ -348,8 +369,8 @@ class VideoPlayerScreenState extends State Future _loadAdjacentEpisodes() async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) return; final playbackState = context.read(); @@ -421,8 +442,8 @@ class VideoPlayerScreenState extends State Future _startPlayback() async { try { - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); if (client == null) { throw Exception('No client available'); } @@ -1462,8 +1483,7 @@ class VideoPlayerScreenState extends State final duration = player!.state.duration.inMilliseconds; if (duration > 0) { - final clientProvider = _cachedClientProvider; - final client = clientProvider?.client; + final client = _cachedClient; if (client == null) return; client @@ -1588,8 +1608,8 @@ class VideoPlayerScreenState extends State } final metadata = widget.metadata; - final clientProvider = context.plexClient; - final client = clientProvider.client; + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); // Get artwork URL String? artworkUrl; @@ -1718,7 +1738,12 @@ class VideoPlayerScreenState extends State try { if (!mounted) return; - final client = context.read(); + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); + if (client == null) { + appLogger.w('No client available to save audio language preference'); + return; + } await client.setMetadataPreferences( targetRatingKey, audioLanguage: languageCode, @@ -1765,7 +1790,12 @@ class VideoPlayerScreenState extends State try { if (!mounted) return; - final client = context.read(); + // Use server-specific client for this metadata + final client = _getClientForMetadata(context); + if (client == null) { + appLogger.w('No client available to save subtitle language preference'); + return; + } await client.setMetadataPreferences( targetRatingKey, subtitleLanguage: languageCode, diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart new file mode 100644 index 00000000..160d7589 --- /dev/null +++ b/lib/services/data_aggregation_service.dart @@ -0,0 +1,364 @@ +import 'dart:async'; + +import '../models/plex_hub.dart'; +import '../models/plex_library.dart'; +import '../models/plex_metadata.dart'; +import '../utils/app_logger.dart'; +import 'multi_server_manager.dart'; + +/// Service for aggregating data from multiple Plex servers +class DataAggregationService { + final MultiServerManager _serverManager; + + DataAggregationService(this._serverManager); + + /// Fetch libraries from all online servers and tag them with server info + Future> getLibrariesFromAllServers() async { + final clients = _serverManager.onlineClients; + + if (clients.isEmpty) { + appLogger.w('No online servers available for fetching libraries'); + return []; + } + + appLogger.d('Fetching libraries from ${clients.length} servers'); + + final allLibraries = []; + + // Fetch from all servers in parallel + final libraryFutures = clients.entries.map((entry) async { + final serverId = entry.key; + final client = entry.value; + final server = _serverManager.getServer(serverId); + + try { + final libraries = await client.getLibraries(); + + // Tag each library with server info + return libraries.map((lib) { + return PlexLibrary( + key: lib.key, + title: lib.title, + type: lib.type, + agent: lib.agent, + scanner: lib.scanner, + language: lib.language, + uuid: lib.uuid, + updatedAt: lib.updatedAt, + createdAt: lib.createdAt, + hidden: lib.hidden, + serverId: serverId, + serverName: server?.name, + ); + }).toList(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to fetch libraries from server $serverId', + error: e, + stackTrace: stackTrace, + ); + // Mark server as offline + _serverManager.updateServerStatus(serverId, false); + return []; + } + }); + + final results = await Future.wait(libraryFutures); + + // Flatten results + for (final libraries in results) { + allLibraries.addAll(libraries); + } + + appLogger.i( + 'Fetched ${allLibraries.length} total libraries from all servers', + ); + + return allLibraries; + } + + /// Fetch "On Deck" (Continue Watching) from all servers and merge by recency + Future> getOnDeckFromAllServers({int? limit}) async { + final clients = _serverManager.onlineClients; + + if (clients.isEmpty) { + appLogger.w('No online servers available for fetching on deck'); + return []; + } + + appLogger.d('Fetching on deck from ${clients.length} servers'); + + final allOnDeck = []; + + // Fetch from all servers in parallel + final onDeckFutures = clients.entries.map((entry) async { + final serverId = entry.key; + final client = entry.value; + final server = _serverManager.getServer(serverId); + + try { + final items = await client.getOnDeck(); + + // Tag each item with server info + return items.map((item) { + return item.copyWith(serverId: serverId, serverName: server?.name); + }).toList(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to fetch on deck from server $serverId', + error: e, + stackTrace: stackTrace, + ); + _serverManager.updateServerStatus(serverId, false); + return []; + } + }); + + final results = await Future.wait(onDeckFutures); + + // Flatten results + for (final items in results) { + allOnDeck.addAll(items); + } + + // Sort by most recent (lastViewedAt is stored in viewOffset metadata) + // For on deck items, we use updatedAt or addedAt as proxy for recency + allOnDeck.sort((a, b) { + final aTime = a.updatedAt ?? a.addedAt ?? 0; + final bTime = b.updatedAt ?? b.addedAt ?? 0; + return bTime.compareTo(aTime); // Descending (most recent first) + }); + + // Apply limit if specified + final result = limit != null && limit < allOnDeck.length + ? allOnDeck.sublist(0, limit) + : allOnDeck; + + appLogger.i('Fetched ${result.length} on deck items from all servers'); + + return result; + } + + /// Fetch recommendation hubs from all servers + Future> getHubsFromAllServers({int? limit}) async { + final clients = _serverManager.onlineClients; + + if (clients.isEmpty) { + appLogger.w('No online servers available for fetching hubs'); + return []; + } + + appLogger.d('Fetching hubs from ${clients.length} servers'); + + final allHubs = []; + + // Fetch from all servers in parallel + final hubFutures = clients.entries.map((entry) async { + final serverId = entry.key; + final client = entry.value; + final server = _serverManager.getServer(serverId); + + try { + // Get libraries for this server + final libraries = await client.getLibraries(); + + // Filter to only visible movie/show libraries + final visibleLibraries = libraries.where((library) { + if (library.type != 'movie' && library.type != 'show') { + return false; + } + if (library.hidden != null && library.hidden != 0) { + return false; + } + return true; + }).toList(); + + // Fetch hubs from all libraries in parallel + final libraryHubFutures = visibleLibraries.map((library) async { + try { + final libraryHubs = await client.getLibraryHubs(library.key); + + // Tag hubs and their items with server info + return libraryHubs.map((hub) { + final taggedItems = hub.items.map((item) { + return item.copyWith( + serverId: serverId, + serverName: server?.name, + ); + }).toList(); + + return PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: hub.size, + more: hub.more, + items: taggedItems, + serverId: serverId, + serverName: server?.name, + ); + }).toList(); + } catch (e) { + appLogger.w( + 'Failed to fetch hubs for library ${library.title}: $e', + ); + return []; + } + }); + + final libraryHubResults = await Future.wait(libraryHubFutures); + + // Flatten all library hubs + final serverHubs = []; + for (final hubs in libraryHubResults) { + serverHubs.addAll(hubs); + } + + return serverHubs; + } catch (e, stackTrace) { + appLogger.e( + 'Failed to fetch hubs from server $serverId', + error: e, + stackTrace: stackTrace, + ); + _serverManager.updateServerStatus(serverId, false); + return []; + } + }); + + final results = await Future.wait(hubFutures); + + // Flatten results + for (final hubs in results) { + allHubs.addAll(hubs); + } + + // Apply limit if specified + final result = limit != null && limit < allHubs.length + ? allHubs.sublist(0, limit) + : allHubs; + + appLogger.i('Fetched ${result.length} hubs from all servers'); + + return result; + } + + /// Search across all online servers + Future> searchAcrossServers( + String query, { + int? limit, + }) async { + final clients = _serverManager.onlineClients; + + if (clients.isEmpty) { + appLogger.w('No online servers available for search'); + return []; + } + + if (query.trim().isEmpty) { + return []; + } + + appLogger.d('Searching for "$query" across ${clients.length} servers'); + + final allResults = []; + + // Search all servers in parallel + final searchFutures = clients.entries.map((entry) async { + final serverId = entry.key; + final client = entry.value; + final server = _serverManager.getServer(serverId); + + try { + final results = await client.search(query); + + // Tag each result with server info + return results.map((item) { + return item.copyWith(serverId: serverId, serverName: server?.name); + }).toList(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to search on server $serverId', + error: e, + stackTrace: stackTrace, + ); + _serverManager.updateServerStatus(serverId, false); + return []; + } + }); + + final results = await Future.wait(searchFutures); + + // Flatten results + for (final items in results) { + allResults.addAll(items); + } + + // Apply limit if specified + final result = limit != null && limit < allResults.length + ? allResults.sublist(0, limit) + : allResults; + + appLogger.i('Found ${result.length} search results across all servers'); + + return result; + } + + /// Get libraries for a specific server + Future> getLibrariesForServer(String serverId) async { + final client = _serverManager.getClient(serverId); + final server = _serverManager.getServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId'); + return []; + } + + try { + final libraries = await client.getLibraries(); + + // Tag with server info + return libraries.map((lib) { + return PlexLibrary( + key: lib.key, + title: lib.title, + type: lib.type, + agent: lib.agent, + scanner: lib.scanner, + language: lib.language, + uuid: lib.uuid, + updatedAt: lib.updatedAt, + createdAt: lib.createdAt, + hidden: lib.hidden, + serverId: serverId, + serverName: server?.name, + ); + }).toList(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to fetch libraries for server $serverId', + error: e, + stackTrace: stackTrace, + ); + _serverManager.updateServerStatus(serverId, false); + return []; + } + } + + /// Group libraries by server + Map> groupLibrariesByServer( + List libraries, + ) { + final grouped = >{}; + + for (final library in libraries) { + final serverId = library.serverId; + if (serverId != null) { + grouped.putIfAbsent(serverId, () => []).add(library); + } + } + + return grouped; + } +} diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart new file mode 100644 index 00000000..d0a950de --- /dev/null +++ b/lib/services/multi_server_manager.dart @@ -0,0 +1,273 @@ +import 'dart:async'; + +import '../client/plex_client.dart'; +import '../config/plex_config.dart'; +import '../utils/app_logger.dart'; +import 'plex_auth_service.dart'; + +/// Manages multiple Plex server connections simultaneously +class MultiServerManager { + /// Map of serverId (clientIdentifier) to PlexClient instances + final Map _clients = {}; + + /// Map of serverId to server info + final Map _servers = {}; + + /// Map of serverId to online status + final Map _serverStatus = {}; + + /// Stream controller for server status changes + final _statusController = StreamController>.broadcast(); + + /// Stream of server status changes + Stream> get statusStream => _statusController.stream; + + /// Get all registered server IDs + List get serverIds => _servers.keys.toList(); + + /// Get all online server IDs + List get onlineServerIds => + _serverStatus.entries.where((e) => e.value).map((e) => e.key).toList(); + + /// Get all offline server IDs + List get offlineServerIds => + _serverStatus.entries.where((e) => !e.value).map((e) => e.key).toList(); + + /// Get client for specific server + PlexClient? getClient(String serverId) => _clients[serverId]; + + /// Get server info for specific server + PlexServer? getServer(String serverId) => _servers[serverId]; + + /// Get all online clients + Map get onlineClients { + final result = {}; + for (final serverId in onlineServerIds) { + final client = _clients[serverId]; + if (client != null) { + result[serverId] = client; + } + } + return result; + } + + /// Get all servers + Map get servers => Map.unmodifiable(_servers); + + /// Check if a server is online + bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false; + + /// Connect to all available servers in parallel + /// Returns the number of successfully connected servers + Future connectToAllServers( + List servers, { + String? clientIdentifier, + Duration timeout = const Duration(seconds: 10), + Function(String serverId, PlexClient client)? onServerConnected, + Function(String serverId, Object error)? onServerFailed, + }) async { + if (servers.isEmpty) { + appLogger.w('No servers to connect to'); + return 0; + } + + appLogger.i('Connecting to ${servers.length} servers...'); + + // Use provided client ID or generate a unique one for this app instance + final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString(); + + // Create connection tasks for all servers + final connectionFutures = servers.map((server) async { + final serverId = server.clientIdentifier; + + try { + appLogger.d('Attempting connection to server: ${server.name}'); + + // Find best working connection for this server + PlexConnection? workingConnection; + + await for (final connection in server.findBestWorkingConnection()) { + workingConnection = connection; + // Use first working connection (could wait for optimal, but that's slower) + break; + } + + if (workingConnection == null) { + throw Exception('No working connection found'); + } + + final baseUrl = workingConnection.uri; + appLogger.d('Connected to ${server.name} at $baseUrl'); + + // Create PlexClient with the working connection + final config = await PlexConfig.create( + baseUrl: baseUrl, + token: server.accessToken, + clientIdentifier: effectiveClientId, + ); + + final client = PlexClient(config); + + // Store the client and server info + _clients[serverId] = client; + _servers[serverId] = server; + _serverStatus[serverId] = true; + + onServerConnected?.call(serverId, client); + appLogger.i('Successfully connected to ${server.name}'); + + return serverId; + } catch (e, stackTrace) { + appLogger.e( + 'Failed to connect to ${server.name}', + error: e, + stackTrace: stackTrace, + ); + + // Mark as offline + _servers[serverId] = server; + _serverStatus[serverId] = false; + + onServerFailed?.call(serverId, e); + return null; + } + }); + + // Wait for all connections with timeout + final results = await Future.wait( + connectionFutures.map((f) => f.timeout( + timeout, + onTimeout: () { + appLogger.w('Server connection timed out'); + return null; + }, + )), + ); + + // Count successful connections + final successCount = results.where((id) => id != null).length; + + // Notify listeners of status change + _statusController.add(Map.from(_serverStatus)); + + appLogger.i( + 'Connected to $successCount/${servers.length} servers successfully', + ); + + return successCount; + } + + /// Add a single server connection + Future addServer( + PlexServer server, { + String? clientIdentifier, + Duration timeout = const Duration(seconds: 10), + }) async { + final serverId = server.clientIdentifier; + final effectiveClientId = clientIdentifier ?? DateTime.now().millisecondsSinceEpoch.toString(); + + try { + appLogger.d('Adding server: ${server.name}'); + + // Find best working connection + PlexConnection? workingConnection; + + await for (final connection in server.findBestWorkingConnection()) { + workingConnection = connection; + break; + } + + if (workingConnection == null) { + throw Exception('No working connection found'); + } + + final baseUrl = workingConnection.uri; + + // Create PlexClient + final config = await PlexConfig.create( + baseUrl: baseUrl, + token: server.accessToken, + clientIdentifier: effectiveClientId, + ); + + final client = PlexClient(config); + + // Store + _clients[serverId] = client; + _servers[serverId] = server; + _serverStatus[serverId] = true; + + // Notify + _statusController.add(Map.from(_serverStatus)); + + appLogger.i('Successfully added server: ${server.name}'); + return true; + } catch (e, stackTrace) { + appLogger.e( + 'Failed to add server ${server.name}', + error: e, + stackTrace: stackTrace, + ); + + _servers[serverId] = server; + _serverStatus[serverId] = false; + _statusController.add(Map.from(_serverStatus)); + + return false; + } + } + + /// Remove a server connection + void removeServer(String serverId) { + _clients.remove(serverId); + _servers.remove(serverId); + _serverStatus.remove(serverId); + _statusController.add(Map.from(_serverStatus)); + appLogger.i('Removed server: $serverId'); + } + + /// Update server status (used for health monitoring) + void updateServerStatus(String serverId, bool isOnline) { + if (_serverStatus[serverId] != isOnline) { + _serverStatus[serverId] = isOnline; + _statusController.add(Map.from(_serverStatus)); + appLogger.d('Server $serverId status changed to: $isOnline'); + } + } + + /// Test connection health for all servers + Future checkServerHealth() async { + appLogger.d('Checking health for ${_clients.length} servers'); + + final healthChecks = _clients.entries.map((entry) async { + 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); + } + }); + + await Future.wait(healthChecks); + } + + /// Disconnect all servers + void disconnectAll() { + appLogger.i('Disconnecting all servers'); + _clients.clear(); + _servers.clear(); + _serverStatus.clear(); + _statusController.add({}); + } + + /// Dispose resources + void dispose() { + disconnectAll(); + _statusController.close(); + } +} diff --git a/lib/services/server_registry.dart b/lib/services/server_registry.dart new file mode 100644 index 00000000..3f827819 --- /dev/null +++ b/lib/services/server_registry.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; + +import '../utils/app_logger.dart'; +import 'plex_auth_service.dart'; +import 'storage_service.dart'; + +/// Centralized server configuration registry +/// Manages which servers are available, enabled/disabled, and their configurations +class ServerRegistry { + static const String _keyServersList = 'servers_list'; + static const String _keyEnabledServers = 'enabled_servers'; + + final StorageService _storage; + + ServerRegistry(this._storage); + + /// Get all registered servers + Future> getServers() async { + try { + final serversJson = _storage.getServersListJson(); + if (serversJson == null || serversJson.isEmpty) { + return []; + } + + final List serversList = jsonDecode(serversJson); + return serversList + .map((json) => PlexServer.fromJson(json as Map)) + .toList(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to load servers from storage', + error: e, + stackTrace: stackTrace, + ); + return []; + } + } + + /// Save all servers to storage + Future saveServers(List servers) async { + try { + final serversJson = jsonEncode(servers.map((s) => s.toJson()).toList()); + await _storage.saveServersListJson(serversJson); + appLogger.d('Saved ${servers.length} servers to storage'); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to save servers to storage', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// Get enabled server IDs + Future> getEnabledServerIds() async { + try { + final enabledJson = _storage.getEnabledServersJson(); + if (enabledJson == null || enabledJson.isEmpty) { + // If no enabled servers are stored, all servers are enabled by default + final servers = await getServers(); + return servers.map((s) => s.clientIdentifier).toSet(); + } + + final List enabledList = jsonDecode(enabledJson); + return enabledList.cast().toSet(); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to load enabled servers from storage', + error: e, + stackTrace: stackTrace, + ); + return {}; + } + } + + /// Save enabled server IDs + Future saveEnabledServerIds(Set serverIds) async { + try { + final enabledJson = jsonEncode(serverIds.toList()); + await _storage.saveEnabledServersJson(enabledJson); + appLogger.d('Saved ${serverIds.length} enabled servers to storage'); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to save enabled servers to storage', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + + /// Get only enabled servers + Future> getEnabledServers() async { + final servers = await getServers(); + final enabledIds = await getEnabledServerIds(); + + if (enabledIds.isEmpty) { + // If no enabled list, all servers are enabled + return servers; + } + + return servers + .where((s) => enabledIds.contains(s.clientIdentifier)) + .toList(); + } + + /// Enable a server + Future enableServer(String serverId) async { + final enabledIds = await getEnabledServerIds(); + enabledIds.add(serverId); + await saveEnabledServerIds(enabledIds); + appLogger.i('Enabled server: $serverId'); + } + + /// Disable a server + Future disableServer(String serverId) async { + final enabledIds = await getEnabledServerIds(); + enabledIds.remove(serverId); + await saveEnabledServerIds(enabledIds); + appLogger.i('Disabled server: $serverId'); + } + + /// Check if a server is enabled + Future isServerEnabled(String serverId) async { + final enabledIds = await getEnabledServerIds(); + return enabledIds.contains(serverId); + } + + /// Get a specific server by ID + Future getServer(String serverId) async { + final servers = await getServers(); + try { + return servers.firstWhere((s) => s.clientIdentifier == serverId); + } catch (e) { + return null; + } + } + + /// Update server status (called when server connection status changes) + Future updateServerStatus(String serverId, { + bool? online, + DateTime? lastSeen, + }) async { + final servers = await getServers(); + final serverIndex = + servers.indexWhere((s) => s.clientIdentifier == serverId); + + if (serverIndex == -1) { + appLogger.w('Server not found for status update: $serverId'); + return; + } + + // Note: PlexServer from auth service doesn't have mutable status fields + // Status tracking is handled by MultiServerManager + // This method is kept for future extension if we add status to PlexServer model + } + + /// Add or update a single server + Future upsertServer(PlexServer server) async { + final servers = await getServers(); + final index = servers.indexWhere( + (s) => s.clientIdentifier == server.clientIdentifier, + ); + + if (index >= 0) { + servers[index] = server; + appLogger.d('Updated server: ${server.name}'); + } else { + servers.add(server); + appLogger.d('Added new server: ${server.name}'); + } + + await saveServers(servers); + + // Ensure new servers are enabled by default + if (index < 0) { + await enableServer(server.clientIdentifier); + } + } + + /// Remove a server + Future removeServer(String serverId) async { + final servers = await getServers(); + servers.removeWhere((s) => s.clientIdentifier == serverId); + await saveServers(servers); + + // Also remove from enabled list + final enabledIds = await getEnabledServerIds(); + enabledIds.remove(serverId); + await saveEnabledServerIds(enabledIds); + + appLogger.i('Removed server: $serverId'); + } + + /// Clear all servers + Future clearAllServers() async { + await _storage.clearServersList(); + await _storage.clearEnabledServers(); + appLogger.i('Cleared all servers from registry'); + } + + /// Migrate from single server storage to multi-server + /// This is called during app startup to migrate existing users + Future migrateFromSingleServer() async { + try { + // Check if we already have servers in the new format + final existingServers = await getServers(); + if (existingServers.isNotEmpty) { + appLogger.d('Servers already migrated, skipping migration'); + return; + } + + // Try to load old single-server data + final oldServerData = _storage.getServerData(); + if (oldServerData == null) { + appLogger.d('No old server data to migrate'); + return; + } + + // Parse and migrate + final server = PlexServer.fromJson(oldServerData); + + appLogger.i('Migrating single server to multi-server: ${server.name}'); + + // Save as first server in new format + await saveServers([server]); + await enableServer(server.clientIdentifier); + + appLogger.i('Migration complete'); + } catch (e, stackTrace) { + appLogger.e( + 'Failed to migrate from single server', + error: e, + stackTrace: stackTrace, + ); + // Don't rethrow - migration failure shouldn't crash the app + } + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index f17ced12..c467d90f 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -19,6 +19,8 @@ class StorageService { static const String _keyHomeUsersCache = 'home_users_cache'; static const String _keyHomeUsersCacheExpiry = 'home_users_cache_expiry'; static const String _keyHiddenLibraries = 'hidden_libraries'; + static const String _keyServersList = 'servers_list'; + static const String _keyEnabledServers = 'enabled_servers'; static StorageService? _instance; late SharedPreferences _prefs; @@ -136,6 +138,7 @@ class StorageService { _prefs.remove(_keyCurrentUserUUID), _prefs.remove(_keyHomeUsersCache), _prefs.remove(_keyHomeUsersCacheExpiry), + clearMultiServerData(), ]); LogRedactionManager.clearTrackedValues(); } @@ -361,7 +364,10 @@ class StorageService { // Clear all user-related data (for logout) Future clearUserData() async { - await Future.wait([clearCredentials(), clearLibraryPreferences()]); + await Future.wait([ + clearCredentials(), + clearLibraryPreferences(), + ]); } // Update current user after switching @@ -371,4 +377,44 @@ class StorageService { saveToken(authToken), // Update the main token ]); } + + // Multi-Server Support Methods + + /// Get servers list as JSON string + String? getServersListJson() { + return _prefs.getString(_keyServersList); + } + + /// Save servers list as JSON string + Future saveServersListJson(String serversJson) async { + await _prefs.setString(_keyServersList, serversJson); + } + + /// Get enabled servers as JSON string + String? getEnabledServersJson() { + return _prefs.getString(_keyEnabledServers); + } + + /// Save enabled servers as JSON string + Future saveEnabledServersJson(String enabledJson) async { + await _prefs.setString(_keyEnabledServers, enabledJson); + } + + /// Clear servers list + Future clearServersList() async { + await _prefs.remove(_keyServersList); + } + + /// Clear enabled servers + Future clearEnabledServers() async { + await _prefs.remove(_keyEnabledServers); + } + + /// Clear all multi-server data + Future clearMultiServerData() async { + await Future.wait([ + clearServersList(), + clearEnabledServers(), + ]); + } } diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index 6203f34b..02e102f6 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -25,6 +25,8 @@ Future playCollectionOrPlaylist({ } String ratingKey = item.ratingKey; + String? serverId = isCollection ? (item as PlexMetadata).serverId : (item as PlexPlaylist).serverId; + String? serverName = isCollection ? (item as PlexMetadata).serverName : (item as PlexPlaylist).serverName; final PlayQueueResponse? playQueue; if (isCollection) { @@ -59,17 +61,38 @@ Future playCollectionOrPlaylist({ fetchedQueue.items!.isNotEmpty) { if (!context.mounted) return; + // Preserve serverId for all items in the queue + final itemsWithServerId = fetchedQueue.items!.map((queueItem) { + return queueItem.copyWith( + serverId: serverId, + serverName: serverName, + ); + }).toList(); + + final queueWithServerId = PlayQueueResponse( + playQueueID: fetchedQueue.playQueueID, + playQueueSelectedItemID: fetchedQueue.playQueueSelectedItemID, + playQueueSelectedItemOffset: fetchedQueue.playQueueSelectedItemOffset, + playQueueSelectedMetadataItemID: fetchedQueue.playQueueSelectedMetadataItemID, + playQueueShuffled: fetchedQueue.playQueueShuffled, + playQueueSourceURI: fetchedQueue.playQueueSourceURI, + playQueueTotalCount: fetchedQueue.playQueueTotalCount, + playQueueVersion: fetchedQueue.playQueueVersion, + size: fetchedQueue.size, + items: itemsWithServerId, + ); + // Set play queue in provider final playbackState = context.read(); playbackState.setClient(client); - await playbackState.setPlaybackFromPlayQueue(fetchedQueue, ratingKey); + await playbackState.setPlaybackFromPlayQueue(queueWithServerId, ratingKey); if (!context.mounted) return; // Navigate to first item await navigateToVideoPlayer( context, - metadata: fetchedQueue.items!.first, + metadata: itemsWithServerId.first, ); return; } @@ -88,15 +111,36 @@ Future playCollectionOrPlaylist({ if (!context.mounted) return; + // Preserve serverId for all items in the queue + final itemsWithServerId = playQueue.items!.map((queueItem) { + return queueItem.copyWith( + serverId: serverId, + serverName: serverName, + ); + }).toList(); + + final queueWithServerId = PlayQueueResponse( + playQueueID: playQueue.playQueueID, + playQueueSelectedItemID: playQueue.playQueueSelectedItemID, + playQueueSelectedItemOffset: playQueue.playQueueSelectedItemOffset, + playQueueSelectedMetadataItemID: playQueue.playQueueSelectedMetadataItemID, + playQueueShuffled: playQueue.playQueueShuffled, + playQueueSourceURI: playQueue.playQueueSourceURI, + playQueueTotalCount: playQueue.playQueueTotalCount, + playQueueVersion: playQueue.playQueueVersion, + size: playQueue.size, + items: itemsWithServerId, + ); + // Set play queue in provider final playbackState = context.read(); playbackState.setClient(client); - await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey); + await playbackState.setPlaybackFromPlayQueue(queueWithServerId, ratingKey); if (!context.mounted) return; // Navigate to first item - await navigateToVideoPlayer(context, metadata: playQueue.items!.first); + await navigateToVideoPlayer(context, metadata: itemsWithServerId.first); } catch (e) { appLogger.e('Failed to ${shuffle ? "shuffle play" : "play"}', error: e); if (context.mounted) { diff --git a/lib/utils/shuffle_play_helper.dart b/lib/utils/shuffle_play_helper.dart index a14ab81e..4a07e7ba 100644 --- a/lib/utils/shuffle_play_helper.dart +++ b/lib/utils/shuffle_play_helper.dart @@ -1,12 +1,35 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; -import '../utils/provider_extensions.dart'; +import '../utils/app_logger.dart'; import '../utils/video_player_navigation.dart'; import '../i18n/strings.g.dart'; +/// Get the correct PlexClient for metadata's server +PlexClient? _getClientForMetadata(BuildContext context, PlexMetadata metadata) { + final serverId = metadata.serverId; + if (serverId == null) { + // Fallback to legacy client if no serverId + appLogger.w('Metadata ${metadata.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; +} + /// Handle shuffle play action for shows and seasons /// /// Fetches episodes based on user settings (unwatched only or including watched), @@ -16,7 +39,7 @@ Future handleShufflePlay( BuildContext context, PlexMetadata metadata, ) async { - final client = context.client; + final client = _getClientForMetadata(context, metadata); if (client == null) return; final playbackState = context.read(); @@ -41,7 +64,12 @@ Future handleShufflePlay( if (itemType == 'show') { if (unwatchedOnly) { // Get only unwatched episodes - episodes = await client.getAllUnwatchedEpisodes(metadata.ratingKey); + final fetchedEpisodes = await client.getAllUnwatchedEpisodes(metadata.ratingKey); + // Preserve serverId for each episode + episodes = fetchedEpisodes.map((ep) => ep.copyWith( + serverId: metadata.serverId, + serverName: metadata.serverName, + )).toList(); } else { // Get all episodes from all seasons final allEpisodes = []; @@ -52,6 +80,10 @@ Future handleShufflePlay( final seasonEpisodes = await client.getChildren(season.ratingKey); final episodesOnly = seasonEpisodes .where((ep) => ep.type == 'episode') + .map((ep) => ep.copyWith( + serverId: metadata.serverId, + serverName: metadata.serverName, + )) .toList(); allEpisodes.addAll(episodesOnly); } @@ -62,13 +94,24 @@ Future handleShufflePlay( // season if (unwatchedOnly) { // Get only unwatched episodes - episodes = await client.getUnwatchedEpisodesInSeason( + final fetchedEpisodes = await client.getUnwatchedEpisodesInSeason( metadata.ratingKey, ); + // Preserve serverId for each episode + episodes = fetchedEpisodes.map((ep) => ep.copyWith( + serverId: metadata.serverId, + serverName: metadata.serverName, + )).toList(); } else { // Get all episodes in season final seasonEpisodes = await client.getChildren(metadata.ratingKey); - episodes = seasonEpisodes.where((ep) => ep.type == 'episode').toList(); + episodes = seasonEpisodes + .where((ep) => ep.type == 'episode') + .map((ep) => ep.copyWith( + serverId: metadata.serverId, + serverName: metadata.serverName, + )) + .toList(); } } diff --git a/lib/widgets/folder_tree_view.dart b/lib/widgets/folder_tree_view.dart index 371a2e48..79905fd0 100644 --- a/lib/widgets/folder_tree_view.dart +++ b/lib/widgets/folder_tree_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; import '../utils/video_player_navigation.dart'; import '../screens/media_detail_screen.dart'; @@ -13,9 +15,15 @@ import '../i18n/strings.g.dart'; /// Shows a hierarchical file/folder structure class FolderTreeView extends StatefulWidget { final String libraryKey; + final String? serverId; // Server this library belongs to final void Function(String)? onRefresh; - const FolderTreeView({super.key, required this.libraryKey, this.onRefresh}); + const FolderTreeView({ + super.key, + required this.libraryKey, + this.serverId, + this.onRefresh, + }); @override State createState() => _FolderTreeViewState(); @@ -29,6 +37,25 @@ class _FolderTreeViewState extends State { bool _isLoadingRoot = false; String? _errorMessage; + /// Get the correct PlexClient for this library's server + PlexClient? _getClientForLibrary() { + final serverId = widget.serverId; + if (serverId == null) { + appLogger.w('Library ${widget.libraryKey} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + @override void initState() { super.initState(); @@ -42,8 +69,7 @@ class _FolderTreeViewState extends State { }); try { - final clientProvider = context.read(); - final client = clientProvider.client; + final client = _getClientForLibrary(); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -52,8 +78,17 @@ class _FolderTreeViewState extends State { if (!mounted) return; + final taggedFolders = folders + .map( + (folder) => folder.copyWith( + serverId: widget.serverId, + serverName: null, // server name not required for folders listing + ), + ) + .toList(); + setState(() { - _rootFolders = folders; + _rootFolders = taggedFolders; _isLoadingRoot = false; }); @@ -89,8 +124,7 @@ class _FolderTreeViewState extends State { }); try { - final clientProvider = context.read(); - final client = clientProvider.client; + final client = _getClientForLibrary(); if (client == null) { throw Exception(t.errors.noClientAvailable); } @@ -99,8 +133,17 @@ class _FolderTreeViewState extends State { if (!mounted) return; + final taggedChildren = children + .map( + (child) => child.copyWith( + serverId: widget.serverId, + serverName: null, + ), + ) + .toList(); + setState(() { - _childrenCache[folder.key] = children; + _childrenCache[folder.key] = taggedChildren; _expandedFolders.add(folder.key); _loadingFolders.remove(folder.key); }); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 629a33b9..d1bc5304 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,15 +1,18 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../utils/duration_formatter.dart'; +import '../utils/app_logger.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../screens/playlist_detail_screen.dart'; @@ -652,6 +655,32 @@ class _MediaCardList extends StatelessWidget { } } +/// Helper to get the correct PlexClient for an item's server +PlexClient? _getClientForItem(BuildContext context, dynamic item) { + String? serverId; + + if (item is PlexMetadata) { + serverId = item.serverId; + } else if (item is PlexPlaylist) { + serverId = item.serverId; + } + + if (serverId == null) { + appLogger.w('Item has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; +} + Widget _buildPosterImage(BuildContext context, dynamic item) { String? posterUrl; IconData fallbackIcon = Icons.movie; @@ -665,9 +694,9 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { } if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + return Builder( + builder: (context) { + final client = _getClientForItem(context, item); if (client == null) { return SkeletonLoader( child: Center( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index babdade0..8f3d7928 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,7 +1,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; +import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/collection_playlist_play_helper.dart'; @@ -57,8 +61,35 @@ class _MediaContextMenuState extends State { _tapPosition = details.globalPosition; } + /// Get the correct PlexClient for this item's server + PlexClient? _getClientForItem() { + String? serverId; + + // Get serverId from the item (could be PlexMetadata or PlexPlaylist) + if (widget.item is PlexMetadata) { + serverId = (widget.item as PlexMetadata).serverId; + } else if (widget.item is PlexPlaylist) { + serverId = (widget.item as PlexPlaylist).serverId; + } + + if (serverId == null) { + appLogger.w('Item has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + void _showContextMenu(BuildContext context) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; final isPlaylist = widget.item is PlexPlaylist; @@ -423,8 +454,7 @@ class _MediaContextMenuState extends State { ) async { if (ratingKey == null) return; - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForItem(); if (client == null) return; try { @@ -447,7 +477,7 @@ class _MediaContextMenuState extends State { /// Show file info bottom sheet Future _showFileInfo(BuildContext context) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; try { @@ -583,7 +613,7 @@ class _MediaContextMenuState extends State { /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; try { @@ -694,7 +724,7 @@ class _MediaContextMenuState extends State { /// Show dialog to select collection and add item Future _showAddToCollectionDialog(BuildContext context) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; try { @@ -916,7 +946,7 @@ class _MediaContextMenuState extends State { BuildContext context, PlexMetadata metadata, ) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; if (widget.collectionId == null) { @@ -992,7 +1022,7 @@ class _MediaContextMenuState extends State { bool isCollection, bool isPlaylist, ) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; await playCollectionOrPlaylist( @@ -1009,7 +1039,7 @@ class _MediaContextMenuState extends State { bool isCollection, bool isPlaylist, ) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; await playCollectionOrPlaylist( @@ -1026,7 +1056,7 @@ class _MediaContextMenuState extends State { bool isCollection, bool isPlaylist, ) async { - final client = context.client; + final client = _getClientForItem(); if (client == null) return; final itemTitle = widget.item.title; diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index 004f771e..90d1845e 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/plex_client_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../utils/duration_formatter.dart'; +import '../utils/app_logger.dart'; import '../i18n/strings.g.dart'; /// Custom list item widget for playlist items @@ -118,12 +121,31 @@ class PlaylistItemCard extends StatelessWidget { ); } + /// Get the correct PlexClient for this item's server + PlexClient? _getClientForItem(BuildContext context) { + final serverId = item.serverId; + if (serverId == null) { + appLogger.w('Playlist item ${item.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + Widget _buildPosterImage(BuildContext context) { final posterUrl = item.posterThumb(); if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + return Builder( + builder: (context) { + final client = _getClientForItem(context); if (client == null) { return _buildPlaceholder(); } diff --git a/lib/widgets/server_badge.dart b/lib/widgets/server_badge.dart new file mode 100644 index 00000000..6a705f9c --- /dev/null +++ b/lib/widgets/server_badge.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; + +/// A small badge widget that indicates which server content is from +/// Shows server name or first letter with optional tooltip +class ServerBadge extends StatelessWidget { + final String? serverName; + final Color? backgroundColor; + final Color? textColor; + final double size; + final bool showFullName; + + const ServerBadge({ + super.key, + required this.serverName, + this.backgroundColor, + this.textColor, + this.size = 20, + this.showFullName = false, + }); + + @override + Widget build(BuildContext context) { + if (serverName == null || serverName!.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final bgColor = backgroundColor ?? + theme.colorScheme.primaryContainer.withOpacity(0.8); + final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer; + + final displayText = showFullName + ? serverName! + : serverName!.substring(0, 1).toUpperCase(); + + final badge = Container( + padding: EdgeInsets.symmetric( + horizontal: showFullName ? 6 : 4, + vertical: 2, + ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + displayText, + style: TextStyle( + color: fgColor, + fontSize: showFullName ? 10 : 11, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ); + + // If showing full name, no need for tooltip + if (showFullName) { + return badge; + } + + // Show tooltip with full server name on hover/long press + return Tooltip( + message: serverName!, + child: badge, + ); + } +} + +/// Server section header for grouping content by server +class ServerSectionHeader extends StatelessWidget { + final String serverName; + final bool isOnline; + final VoidCallback? onTap; + final Widget? trailing; + + const ServerSectionHeader({ + super.key, + required this.serverName, + this.isOnline = true, + this.onTap, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + child: Row( + children: [ + // Server status indicator + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isOnline ? Colors.green : Colors.grey, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + // Server name + Expanded( + child: Text( + serverName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ), + // Optional trailing widget + if (trailing != null) trailing!, + ], + ), + ), + ); + } +} diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart deleted file mode 100644 index 427d7c40..00000000 --- a/lib/widgets/server_list_tile.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import '../services/plex_auth_service.dart'; -import '../i18n/strings.g.dart'; - -class ServerListTile extends StatelessWidget { - final PlexServer server; - final VoidCallback onTap; - final bool showTrailingIcon; - final bool isCurrentServer; - - const ServerListTile({ - super.key, - required this.server, - required this.onTap, - this.showTrailingIcon = true, - this.isCurrentServer = false, - }); - - @override - Widget build(BuildContext context) { - final isOnline = server.isOnline; - - return Semantics( - selected: isCurrentServer, - identifier: server.name, - label: server.name, - child: ListTile( - key: ValueKey(server.name), - leading: Icon( - Icons.dns, - color: isOnline - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - semanticLabel: t.common.server, - ), - title: Text(server.name), - subtitle: Semantics( - label: - '${isOnline ? t.common.online : t.common.offline}, ${server.owned ? t.common.owned : t.common.shared}', - excludeSemantics: true, - child: Row( - children: [ - Semantics( - excludeSemantics: true, - child: Icon( - isOnline ? Icons.circle : Icons.circle_outlined, - size: 10, - color: isOnline ? Colors.green : Colors.grey, - ), - ), - const SizedBox(width: 4), - Text( - isOnline ? t.common.online : t.common.offline, - style: TextStyle( - fontSize: 12, - color: isOnline ? Colors.green : Colors.grey, - ), - ), - const SizedBox(width: 8), - Semantics( - excludeSemantics: true, - child: Text( - '•', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - ), - const SizedBox(width: 8), - Text( - server.owned ? t.common.owned : t.common.shared, - style: const TextStyle(fontSize: 12), - ), - ], - ), - ), - trailing: isCurrentServer - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - t.common.current, - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ) - : (showTrailingIcon ? const Icon(Icons.chevron_right) : null), - onTap: isCurrentServer ? null : onTap, - enabled: !isCurrentServer, - ), - ); - } -} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 5c615eb1..fbf3aa4b 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:provider/provider.dart'; +import '../../../client/plex_client.dart'; import '../../../models/plex_media_info.dart'; import '../../../providers/plex_client_provider.dart'; +import '../../../providers/multi_server_provider.dart'; import '../../../utils/duration_formatter.dart'; +import '../../../utils/app_logger.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting chapters @@ -11,30 +14,52 @@ class ChapterSheet extends StatelessWidget { final Player player; final List chapters; final bool chaptersLoaded; + final String? serverId; // Server ID for the metadata these chapters belong to const ChapterSheet({ super.key, required this.player, required this.chapters, required this.chaptersLoaded, + this.serverId, }); static void show( BuildContext context, Player player, List chapters, - bool chaptersLoaded, - ) { + bool chaptersLoaded, { + String? serverId, + }) { BaseVideoControlSheet.showSheet( context: context, builder: (context) => ChapterSheet( player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, + serverId: serverId, ), ); } + /// Get the correct PlexClient for the metadata's server + PlexClient? _getClientForChapters(BuildContext context) { + if (serverId == null) { + appLogger.w('Chapters have no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId!); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } + @override Widget build(BuildContext context) { return StreamBuilder( @@ -84,9 +109,9 @@ class ChapterSheet extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; + child: Builder( + builder: (context) { + final client = _getClientForChapters(context); if (client == null) { return const Icon( Icons.image, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index be939e5f..10a0cd11 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -5,16 +5,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; +import '../../client/plex_client.dart'; import '../../models/plex_media_info.dart'; import '../../models/plex_media_version.dart'; import '../../models/plex_metadata.dart'; +import '../../providers/plex_client_provider.dart'; +import '../../providers/multi_server_provider.dart'; import '../../screens/video_player_screen.dart'; import '../../services/fullscreen_state_manager.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; import '../../services/sleep_timer_service.dart'; +import '../../utils/app_logger.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/duration_formatter.dart'; import '../../utils/platform_detector.dart'; @@ -99,6 +104,25 @@ class _PlexVideoControlsState extends State int _audioSyncOffset = 0; // Default, loaded from settings int _subtitleSyncOffset = 0; // Default, loaded from settings bool _isRotationLocked = true; // Default locked (landscape only) + + /// Get the correct PlexClient for this metadata's server + PlexClient? _getClientForMetadata() { + final serverId = widget.metadata.serverId; + if (serverId == null) { + appLogger.w('Metadata ${widget.metadata.title} has no serverId, using legacy client'); + return context.read().client; + } + + final multiServerProvider = context.read(); + final client = multiServerProvider.getClientForServer(serverId); + + if (client == null) { + appLogger.w('No client found for server $serverId, using legacy client'); + return context.read().client; + } + + return client; + } // Double-tap feedback state bool _showDoubleTapFeedback = false; double _doubleTapFeedbackOpacity = 0.0; @@ -344,8 +368,7 @@ class _PlexVideoControlsState extends State } Future _loadChapters() async { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(); if (client == null) return; final chapters = await client.getChapters(widget.metadata.ratingKey); @@ -358,8 +381,7 @@ class _PlexVideoControlsState extends State } Future _loadMarkers() async { - final clientProvider = context.plexClient; - final client = clientProvider.client; + final client = _getClientForMetadata(); if (client == null) return; final markers = await client.getMarkers(widget.metadata.ratingKey); @@ -500,6 +522,7 @@ class _PlexVideoControlsState extends State widget.player, _chapters, _chaptersLoaded, + serverId: widget.metadata.serverId, ), ), if (widget.availableVersions.length > 1)