feat: remove server switcher
This commit is contained in:
@@ -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
@@ -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 '未找到服务器';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -399,6 +399,11 @@
|
||||
},
|
||||
"serverSelection": {
|
||||
"connectingToServer": "正在连接服务器...",
|
||||
"connectingToAllServers": "正在连接所有服务器...",
|
||||
"connectToAllServers": "连接所有服务器",
|
||||
"allServerConnectionsFailed": "无法连接到任何服务器。请检查你的网络并重试。",
|
||||
"foundServers": "找到 ${count} 个服务器",
|
||||
"orSelectIndividually": "或单独选择一个服务器:",
|
||||
"serverDebugCopied": "服务器调试数据已复制到剪贴板",
|
||||
"copyDebugData": "复制调试数据",
|
||||
"noServersFound": "未找到服务器",
|
||||
|
||||
+101
-72
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user