feat: remove server switcher

This commit is contained in:
edde746
2025-11-21 23:59:39 +01:00
parent b1bed439eb
commit 6e8a1a373e
46 changed files with 2998 additions and 1205 deletions
+13 -3
View File
@@ -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,
);
}
}
}
+316 -260
View File
@@ -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 '未找到服务器';
+5 -2
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -399,6 +399,11 @@
},
"serverSelection": {
"connectingToServer": "正在连接服务器...",
"connectingToAllServers": "正在连接所有服务器...",
"connectToAllServers": "连接所有服务器",
"allServerConnectionsFailed": "无法连接到任何服务器。请检查你的网络并重试。",
"foundServers": "找到 ${count} 个服务器",
"orSelectIndividually": "或单独选择一个服务器:",
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
"copyDebugData": "复制调试数据",
"noServersFound": "未找到服务器",
+101 -72
View File
@@ -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<SetupScreen> {
Future<void> _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<MultiServerProvider>(
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<PlexClientProvider>(
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()),
);
}
}
}
+6
View File
@@ -10,6 +10,10 @@ class PlexHub {
final bool more;
final List<PlexMetadata> 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<String, dynamic> json) {
+11
View File
@@ -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<String, dynamic> json) =>
+15
View File
@@ -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;
+56
View File
@@ -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<String, dynamic> 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,
);
}
}
+97
View File
@@ -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<String> get onlineServerIds => _serverManager.onlineServerIds;
/// Get all server IDs
List<String> 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<void> checkServerHealth() async {
await _serverManager.checkServerHealth();
// notifyListeners() will be called automatically via status stream
}
@override
void dispose() {
_serverManager.dispose();
super.dispose();
}
}
+20 -2
View File
@@ -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;
+76
View File
@@ -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<String, bool> _serverConnectionStates = {};
final Map<String, String?> _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();
}
}
+82 -22
View File
@@ -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<AuthScreen> {
_authService = await PlexAuthService.create();
}
/// Connect to all available servers and navigate to main screen
Future<void> _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<MultiServerProvider>();
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>();
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<void> _startAuthentication() async {
setState(() {
_isAuthenticating = true;
@@ -100,23 +174,16 @@ class _AuthScreenState extends State<AuthScreen> {
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<AuthScreen> {
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(() {
+41 -4
View File
@@ -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<T extends StatefulWidget>
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<T extends StatefulWidget>
/// 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<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
@override
void initState() {
super.initState();
@@ -54,9 +93,7 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
return;
}
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
final client = _getClientForMediaItem();
await playCollectionOrPlaylist(
context: context,
+34 -3
View File
@@ -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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
Future<void> 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(
+65 -121
View File
@@ -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<DiscoverScreen>
static const Duration _heroAutoScrollDuration = Duration(seconds: 8);
@override
PlexClient get client => context.clientSafe;
PlexClient get client => _getClientForItem(null);
List<PlexMetadata> _onDeck = [];
List<PlexHub> _hubs = [];
@@ -53,6 +54,26 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
@override
void initState() {
super.initState();
@@ -160,61 +181,51 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
Future<void> _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<MultiServerProvider>(
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 = <PlexHub>[];
final onDeck = results[0] as List<PlexMetadata>;
final allHubs = results[1] as List<PlexHub>;
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<DiscoverScreen>
/// Refresh only the Continue Watching section in the background
/// This is called when returning to the home screen to avoid blocking UI
Future<void> _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<MultiServerProvider>();
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<DiscoverScreen>
}
}
Future<void> _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<void> _handleLogout() async {
final confirm = await showDialog<bool>(
context: context,
@@ -523,8 +497,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
],
),
),
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<DiscoverScreen>
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<DiscoverScreen>
child: Opacity(opacity: value, child: child),
);
},
child: Consumer<PlexClientProvider>(
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<DiscoverScreen>
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
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<DiscoverScreen>
return InkWell(
onTap: () {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
appLogger.d('Playing: ${heroItem.title}');
navigateToVideoPlayer(context, metadata: heroItem);
},
+35 -12
View File
@@ -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<HubDetailScreen> with Refreshable {
PlexClient get client => context.clientSafe;
PlexClient get client => _getClientForHub();
List<PlexMetadata> _items = [];
List<PlexMetadata> _filteredItems = [];
@@ -35,6 +37,25 @@ class _HubDetailScreenState extends State<HubDetailScreen> 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<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
@override
void initState() {
super.initState();
@@ -51,9 +72,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
Future<void> _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<HubDetailScreen> 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<HubDetailScreen> 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);
+275 -121
View File
@@ -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<LibrariesScreen>
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<PlexClientProvider>().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<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
// GlobalKeys for tabs to enable refresh
final _recommendedTabKey = GlobalKey<State<LibraryRecommendedTab>>();
final _browseTabKey = GlobalKey<State<LibraryBrowseTab>>();
@@ -44,7 +74,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
List<PlexLibrary> _allLibraries = []; // All libraries from API (unfiltered)
bool _isLoadingLibraries = true;
String? _errorMessage;
String? _selectedLibraryKey;
String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true;
Map<String, String> _selectedFilters = {};
@@ -67,9 +97,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
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<void> _loadLibraries() async {
// Extract context dependencies before async gap
final clientProvider = context.plexClient;
final multiServerProvider = Provider.of<MultiServerProvider>(
context,
listen: false,
);
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
context,
listen: false,
@@ -112,13 +154,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
});
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<LibrariesScreen>
.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<LibrariesScreen>
}
// 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 = <PlexLibrary>[];
@@ -209,7 +253,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// 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<LibrariesScreen>
Future<void> _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<void> _loadLibraryContent(String libraryKey) async {
Future<void> _loadLibraryContent(String libraryGlobalKey) async {
// Compute visible libraries based on current provider state
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
context,
@@ -236,25 +280,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// 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<LibrariesScreen>
// 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<LibrariesScreen>
// 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<LibrariesScreen>
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<LibrariesScreen>
/// Load all pages sequentially until all items are fetched
Future<void> _loadAllPagesSequentially(
String libraryKey,
PlexLibrary library,
Map<String, String> filtersWithSort,
int requestId,
PlexClient client,
@@ -331,22 +370,32 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
}
}
Future<void> _loadSortOptions(String libraryKey) async {
Future<void> _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<LibrariesScreen>
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<LibrariesScreen>
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<LibrariesScreen>
.toList();
if (visibleLibraries.isNotEmpty) {
_loadLibraryContent(visibleLibraries.first.key);
_loadLibraryContent(visibleLibraries.first.globalKey);
}
}
}
@@ -603,11 +648,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
);
}
/// Build grouped dropdown menu items with server sections
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(
List<PlexLibrary> visibleLibraries,
) {
final List<PopupMenuEntry<String>> menuItems = [];
if (!_hasMultipleServers) {
// Single server: flat list
return visibleLibraries.map((library) {
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
return PopupMenuItem<String>(
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<String, List<PlexLibrary>> 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<String>(
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<String>(
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<LibrariesScreen>
Widget _buildLibraryDropdownTitle(List<PlexLibrary> visibleLibraries) {
final selectedLibrary = visibleLibraries.firstWhere(
(lib) => lib.key == _selectedLibraryKey,
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
orElse: () => visibleLibraries.first,
);
return PopupMenuButton<String>(
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<String>(
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<LibrariesScreen>
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<LibrariesScreen>
)
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<LibrariesScreen>
),
// Tab content
if (_selectedLibraryKey != null)
if (_selectedLibraryGlobalKey != null)
SliverFillRemaining(
child: TabBarView(
controller: _tabController,
@@ -883,25 +1022,25 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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: [
@@ -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<LibraryBrowseTab>
@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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
void refresh() {
_loadContent();
@@ -89,7 +111,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
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<LibraryBrowseTab>
_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<LibraryBrowseTab>
});
try {
final client = clientProvider.client;
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
@@ -131,10 +152,10 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
// 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<LibraryBrowseTab>
});
try {
final client = context.read<PlexClientProvider>().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<LibraryBrowseTab>
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<LibraryBrowseTab>
_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<LibraryBrowseTab>
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<LibraryBrowseTab>
StorageService.getInstance().then((storage) {
storage.saveLibrarySort(
widget.library.key,
widget.library.globalKey,
sort.key,
descending: descending,
);
@@ -487,6 +519,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
if (_selectedGrouping == 'folders') {
return FolderTreeView(
libraryKey: widget.library.key,
serverId: widget.library.serverId,
onRefresh: updateItem,
);
}
@@ -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<LibraryCollectionsTab>
_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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexMetadata> _collections = [];
bool _isLoading = false;
String? _errorMessage;
@@ -69,7 +91,7 @@ class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
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<LibraryCollectionsTab>
});
try {
final client = context.read<PlexClientProvider>().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<LibraryCollectionsTab>
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) {
@@ -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<LibraryPlaylistsTab>
_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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexPlaylist> _playlists = [];
bool _isLoading = false;
String? _errorMessage;
@@ -70,7 +92,7 @@ class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
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<LibraryPlaylistsTab>
});
try {
final client = context.read<PlexClientProvider>().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<LibraryPlaylistsTab>
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) {
@@ -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<LibraryRecommendedTab>
_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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
List<PlexHub> _hubs = [];
bool _isLoading = false;
String? _errorMessage;
@@ -44,7 +66,7 @@ class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
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<LibraryRecommendedTab>
});
try {
final client = context.read<PlexClientProvider>().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<LibraryRecommendedTab>
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) {
+82 -37
View File
@@ -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<MediaDetailScreen> {
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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
Future<void> _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<MediaDetailScreen> {
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<MediaDetailScreen> {
});
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<MediaDetailScreen> {
/// This preserves scroll position and only updates watch-related data
Future<void> _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<MediaDetailScreen> {
);
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<PlexMetadata>? 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<MediaDetailScreen> {
Future<void> _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<MediaDetailScreen> {
// 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<MediaDetailScreen> {
children: [
// Background Art
if (metadata.art != null)
Consumer<PlexClientProvider>(
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<MediaDetailScreen> {
SizedBox(
height: 120,
width: 400,
child: Consumer<PlexClientProvider>(
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<MediaDetailScreen> {
// 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<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
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<MediaDetailScreen> {
if (season.thumb != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
child: Builder(
builder: (context) {
final client = _getClientForMetadata(context);
if (client == null) {
return Container(
width: 80,
+34 -3
View File
@@ -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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
Future<void> 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];
+11 -5
View File
@@ -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<SearchScreen>
});
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
final multiServerProvider = Provider.of<MultiServerProvider>(
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;
+39 -25
View File
@@ -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<SeasonDetailScreen>
with ItemUpdatable {
late final PlexClient _client;
@override
PlexClient get client => context.clientSafe;
PlexClient get client => _client;
List<PlexMetadata> _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<PlexClientProvider>().client!;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client!;
}
return client;
}
@override
void initState() {
super.initState();
_loadEpisodes();
// Initialize the client once in initState
WidgetsBinding.instance.addPostFrameCallback((_) {
_client = _getClientForSeason(context);
_loadEpisodes();
});
}
Future<void> _loadEpisodes() async {
@@ -43,15 +69,14 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
});
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<SeasonDetailScreen>
child: AspectRatio(
aspectRatio: 16 / 9,
child: episode.thumb != null
? Consumer<PlexClientProvider>(
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,
-329
View File
@@ -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<ServerSelectionScreen> createState() => _ServerSelectionScreenState();
}
class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
List<PlexServer>? _servers;
bool _isLoading = true;
String? _errorMessage;
String? _currentServerUrl;
List<Map<String, dynamic>>? _debugServerData;
@override
void initState() {
super.initState();
_loadCurrentServerUrl();
_loadServers();
}
Future<void> _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<void> _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<void> _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<void> _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),
),
),
);
},
),
),
],
),
);
}
}
+45 -15
View File
@@ -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<VideoPlayerScreen>
PlexMetadata? _previousEpisode;
bool _isLoadingNext = false;
bool _showPlayNextDialog = false;
PlexClientProvider? _cachedClientProvider;
PlexClient? _cachedClient;
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
StreamSubscription<PlayerLog>? _logSubscription;
@@ -71,6 +72,26 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
// 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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().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<VideoPlayerScreen>
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<VideoPlayerScreen>
Future<void> _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<PlaybackStateProvider>();
@@ -421,8 +442,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
Future<void> _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<VideoPlayerScreen>
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<VideoPlayerScreen>
}
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<VideoPlayerScreen>
try {
if (!mounted) return;
final client = context.read<PlexClient>();
// 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<VideoPlayerScreen>
try {
if (!mounted) return;
final client = context.read<PlexClient>();
// 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,
+364
View File
@@ -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<List<PlexLibrary>> 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 = <PlexLibrary>[];
// 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 <PlexLibrary>[];
}
});
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<List<PlexMetadata>> 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 = <PlexMetadata>[];
// 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 <PlexMetadata>[];
}
});
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<List<PlexHub>> 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 = <PlexHub>[];
// 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 <PlexHub>[];
}
});
final libraryHubResults = await Future.wait(libraryHubFutures);
// Flatten all library hubs
final serverHubs = <PlexHub>[];
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 <PlexHub>[];
}
});
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<List<PlexMetadata>> 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 = <PlexMetadata>[];
// 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 <PlexMetadata>[];
}
});
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<List<PlexLibrary>> 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<String, List<PlexLibrary>> groupLibrariesByServer(
List<PlexLibrary> libraries,
) {
final grouped = <String, List<PlexLibrary>>{};
for (final library in libraries) {
final serverId = library.serverId;
if (serverId != null) {
grouped.putIfAbsent(serverId, () => []).add(library);
}
}
return grouped;
}
}
+273
View File
@@ -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<String, PlexClient> _clients = {};
/// Map of serverId to server info
final Map<String, PlexServer> _servers = {};
/// Map of serverId to online status
final Map<String, bool> _serverStatus = {};
/// Stream controller for server status changes
final _statusController = StreamController<Map<String, bool>>.broadcast();
/// Stream of server status changes
Stream<Map<String, bool>> get statusStream => _statusController.stream;
/// Get all registered server IDs
List<String> get serverIds => _servers.keys.toList();
/// Get all online server IDs
List<String> get onlineServerIds =>
_serverStatus.entries.where((e) => e.value).map((e) => e.key).toList();
/// Get all offline server IDs
List<String> 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<String, PlexClient> get onlineClients {
final result = <String, PlexClient>{};
for (final serverId in onlineServerIds) {
final client = _clients[serverId];
if (client != null) {
result[serverId] = client;
}
}
return result;
}
/// Get all servers
Map<String, PlexServer> 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<int> connectToAllServers(
List<PlexServer> 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<bool> 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<void> 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();
}
}
+240
View File
@@ -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<List<PlexServer>> getServers() async {
try {
final serversJson = _storage.getServersListJson();
if (serversJson == null || serversJson.isEmpty) {
return [];
}
final List<dynamic> serversList = jsonDecode(serversJson);
return serversList
.map((json) => PlexServer.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e, stackTrace) {
appLogger.e(
'Failed to load servers from storage',
error: e,
stackTrace: stackTrace,
);
return [];
}
}
/// Save all servers to storage
Future<void> saveServers(List<PlexServer> 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<Set<String>> 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<dynamic> enabledList = jsonDecode(enabledJson);
return enabledList.cast<String>().toSet();
} catch (e, stackTrace) {
appLogger.e(
'Failed to load enabled servers from storage',
error: e,
stackTrace: stackTrace,
);
return {};
}
}
/// Save enabled server IDs
Future<void> saveEnabledServerIds(Set<String> 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<List<PlexServer>> 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<void> enableServer(String serverId) async {
final enabledIds = await getEnabledServerIds();
enabledIds.add(serverId);
await saveEnabledServerIds(enabledIds);
appLogger.i('Enabled server: $serverId');
}
/// Disable a server
Future<void> 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<bool> isServerEnabled(String serverId) async {
final enabledIds = await getEnabledServerIds();
return enabledIds.contains(serverId);
}
/// Get a specific server by ID
Future<PlexServer?> 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<void> 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<void> 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<void> 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<void> 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<void> 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
}
}
}
+47 -1
View File
@@ -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<void> 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<void> 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<void> saveEnabledServersJson(String enabledJson) async {
await _prefs.setString(_keyEnabledServers, enabledJson);
}
/// Clear servers list
Future<void> clearServersList() async {
await _prefs.remove(_keyServersList);
}
/// Clear enabled servers
Future<void> clearEnabledServers() async {
await _prefs.remove(_keyEnabledServers);
}
/// Clear all multi-server data
Future<void> clearMultiServerData() async {
await Future.wait([
clearServersList(),
clearEnabledServers(),
]);
}
}
+48 -4
View File
@@ -25,6 +25,8 @@ Future<void> 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<void> 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<PlaybackStateProvider>();
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<void> 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<PlaybackStateProvider>();
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) {
+48 -5
View File
@@ -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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().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<void> handleShufflePlay(
BuildContext context,
PlexMetadata metadata,
) async {
final client = context.client;
final client = _getClientForMetadata(context, metadata);
if (client == null) return;
final playbackState = context.read<PlaybackStateProvider>();
@@ -41,7 +64,12 @@ Future<void> 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 = <PlexMetadata>[];
@@ -52,6 +80,10 @@ Future<void> 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<void> 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();
}
}
+50 -7
View File
@@ -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<FolderTreeView> createState() => _FolderTreeViewState();
@@ -29,6 +37,25 @@ class _FolderTreeViewState extends State<FolderTreeView> {
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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
void initState() {
super.initState();
@@ -42,8 +69,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
});
try {
final clientProvider = context.read<PlexClientProvider>();
final client = clientProvider.client;
final client = _getClientForLibrary();
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
@@ -52,8 +78,17 @@ class _FolderTreeViewState extends State<FolderTreeView> {
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<FolderTreeView> {
});
try {
final clientProvider = context.read<PlexClientProvider>();
final client = clientProvider.client;
final client = _getClientForLibrary();
if (client == null) {
throw Exception(t.errors.noClientAvailable);
}
@@ -99,8 +133,17 @@ class _FolderTreeViewState extends State<FolderTreeView> {
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);
});
+32 -3
View File
@@ -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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().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<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
return Builder(
builder: (context) {
final client = _getClientForItem(context, item);
if (client == null) {
return SkeletonLoader(
child: Center(
+40 -10
View File
@@ -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<MediaContextMenu> {
_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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().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<MediaContextMenu> {
) 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<MediaContextMenu> {
/// Show file info bottom sheet
Future<void> _showFileInfo(BuildContext context) async {
final client = context.client;
final client = _getClientForItem();
if (client == null) return;
try {
@@ -583,7 +613,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
/// Show dialog to select playlist and add item
Future<void> _showAddToPlaylistDialog(BuildContext context) async {
final client = context.client;
final client = _getClientForItem();
if (client == null) return;
try {
@@ -694,7 +724,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
/// Show dialog to select collection and add item
Future<void> _showAddToCollectionDialog(BuildContext context) async {
final client = context.client;
final client = _getClientForItem();
if (client == null) return;
try {
@@ -916,7 +946,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
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<MediaContextMenu> {
bool isCollection,
bool isPlaylist,
) async {
final client = context.client;
final client = _getClientForItem();
if (client == null) return;
final itemTitle = widget.item.title;
+25 -3
View File
@@ -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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
Widget _buildPosterImage(BuildContext context) {
final posterUrl = item.posterThumb();
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
return Builder(
builder: (context) {
final client = _getClientForItem(context);
if (client == null) {
return _buildPlaceholder();
}
+122
View File
@@ -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!,
],
),
),
);
}
}
-103
View File
@@ -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,
),
);
}
}
@@ -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<PlexChapter> 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<PlexChapter> 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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId!);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration>(
@@ -84,9 +109,9 @@ class ChapterSheet extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) {
final client = clientProvider.client;
child: Builder(
builder: (context) {
final client = _getClientForChapters(context);
if (client == null) {
return const Icon(
Icons.image,
+27 -4
View File
@@ -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<PlexVideoControls>
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<PlexClientProvider>().client;
}
final multiServerProvider = context.read<MultiServerProvider>();
final client = multiServerProvider.getClientForServer(serverId);
if (client == null) {
appLogger.w('No client found for server $serverId, using legacy client');
return context.read<PlexClientProvider>().client;
}
return client;
}
// Double-tap feedback state
bool _showDoubleTapFeedback = false;
double _doubleTapFeedbackOpacity = 0.0;
@@ -344,8 +368,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
Future<void> _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<PlexVideoControls>
}
Future<void> _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<PlexVideoControls>
widget.player,
_chapters,
_chaptersLoaded,
serverId: widget.metadata.serverId,
),
),
if (widget.availableVersions.length > 1)