From 44be03d39ca49e3e5d60756ca874058cbaaaaf97 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:18:26 +0200 Subject: [PATCH] refactor(profiles): shared auth/mint flows + screen fixes --- lib/connection/plex_account_setup.dart | 119 +++++++++++++++ lib/i18n/bg.i18n.json | 2 + lib/i18n/da.i18n.json | 2 + lib/i18n/de.i18n.json | 2 + lib/i18n/en.i18n.json | 2 + lib/i18n/es.i18n.json | 2 + lib/i18n/fr.i18n.json | 2 + lib/i18n/it.i18n.json | 2 + lib/i18n/ja.i18n.json | 2 + lib/i18n/ko.i18n.json | 2 + lib/i18n/nb.i18n.json | 2 + lib/i18n/nl.i18n.json | 2 + lib/i18n/pl.i18n.json | 2 + lib/i18n/pt.i18n.json | 2 + lib/i18n/ru.i18n.json | 2 + lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_bg.g.dart | 8 +- lib/i18n/strings_da.g.dart | 8 +- lib/i18n/strings_de.g.dart | 8 +- lib/i18n/strings_en.g.dart | 12 +- lib/i18n/strings_es.g.dart | 8 +- lib/i18n/strings_fr.g.dart | 8 +- lib/i18n/strings_it.g.dart | 8 +- lib/i18n/strings_ja.g.dart | 8 +- lib/i18n/strings_ko.g.dart | 8 +- lib/i18n/strings_nb.g.dart | 8 +- lib/i18n/strings_nl.g.dart | 8 +- lib/i18n/strings_pl.g.dart | 8 +- lib/i18n/strings_pt.g.dart | 8 +- lib/i18n/strings_ru.g.dart | 8 +- lib/i18n/strings_sv.g.dart | 8 +- lib/i18n/strings_zh.g.dart | 8 +- lib/i18n/sv.i18n.json | 2 + lib/i18n/zh.i18n.json | 2 + lib/profiles/plex_home_service.dart | 15 +- lib/profiles/plex_home_switch.dart | 52 ++++++- lib/profiles/profile_activation.dart | 117 +++++++-------- lib/screens/auth_screen.dart | 63 ++++---- lib/screens/main_screen.dart | 28 ++++ .../profile/add_local_profile_screen.dart | 41 ++--- .../profile/borrow_connection_screen.dart | 110 ++++++-------- lib/screens/profile/pin_status_row.dart | 7 +- .../profile/profile_detail_screen.dart | 142 +++++++++++++++--- .../profile/profile_switch_screen.dart | 57 +++++-- .../settings/add_plex_account_screen.dart | 118 ++++++--------- .../settings/connection_persistence.dart | 11 +- lib/screens/settings/settings_screen.dart | 44 +++--- .../profile/profile_detail_screen_test.dart | 16 +- 48 files changed, 761 insertions(+), 345 deletions(-) create mode 100644 lib/connection/plex_account_setup.dart diff --git a/lib/connection/plex_account_setup.dart b/lib/connection/plex_account_setup.dart new file mode 100644 index 00000000..b471968a --- /dev/null +++ b/lib/connection/plex_account_setup.dart @@ -0,0 +1,119 @@ +import '../profiles/plex_home_service.dart'; +import '../profiles/profile_connection_registry.dart'; +import '../services/plex_auth_service.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import 'connection.dart'; +import 'connection_registry.dart'; + +/// Outcome of [registerPlexAccountFromToken]. [homeUsersFetched] is false +/// when the `/home/users` fetch failed — first-sign-in flows can't build +/// any profile without it and must not conflate that with "no users". +/// [existedBefore] tells add-flows whether a cancelled attach should +/// remove the account again (it was created solely for the attach). +typedef PlexAccountRegistration = ({ + PlexAccountConnection connection, + bool homeUsersFetched, + bool existedBefore, + String username, + String email, +}); + +/// Shared post-auth pipeline for a fresh plex.tv [token]: resolve the +/// account identity, persist the [PlexAccountConnection] (folding in a +/// legacy client-id-keyed row from an earlier failed identity lookup), and +/// refresh its Plex Home users. Used by the first-sign-in AuthScreen and +/// the add-account settings flow so identity/dedup policy can't drift. +Future registerPlexAccountFromToken({ + required String token, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + required StorageService storage, + required PlexHomeService plexHome, +}) async { + final auth = await PlexAuthService.create(); + try { + // Account identity from plex.tv — the uuid is what makes multi-account + // work (the clientIdentifier is per-device and identical for every Plex + // account on this install). Falls back to the client identifier only + // when the user-info call fails outright; a later successful sign-in + // migrates that legacy row below. + String username = ''; + String email = ''; + String accountUuid = ''; + try { + final info = await auth.getUserInfo(token); + username = (info['username'] as String?) ?? ''; + email = (info['email'] as String?) ?? ''; + accountUuid = (info['uuid'] as String?)?.trim() ?? ''; + } catch (e) { + appLogger.d('getUserInfo after Plex sign-in failed (using fallback identity): $e'); + } + + final servers = await auth.fetchServers(token); + final connection = PlexAccountConnection( + id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}', + accountToken: token, + clientIdentifier: auth.clientIdentifier, + accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'), + servers: servers, + createdAt: DateTime.now(), + lastAuthenticatedAt: DateTime.now(), + ); + final existedBefore = await connections.get(connection.id) != null; + await connections.upsert(connection); + + if (accountUuid.isNotEmpty) { + await _migrateLegacyClientIdRow( + replacement: connection, + clientIdentifier: auth.clientIdentifier, + connections: connections, + profileConnections: profileConnections, + storage: storage, + ); + } + + // Fetch home users now so pickers surface the account's virtual + // profiles immediately. + final homeUsersFetched = await plexHome.refresh(connection); + return ( + connection: connection, + homeUsersFetched: homeUsersFetched, + existedBefore: existedBefore, + username: username, + email: email, + ); + } finally { + auth.dispose(); + } +} + +/// A row keyed by the per-device client identifier (created when an earlier +/// sign-in couldn't resolve the account uuid) would duplicate the account — +/// and collides with every other account on this device. Fold it into the +/// uuid-keyed [replacement]: re-point its join rows, then remove it. +/// +/// Virtual profile ids that embedded the legacy account id are not +/// rewritten; their (rare) borrowed rows become orphans that the startup +/// prune and post-removal settle already clean up. +Future _migrateLegacyClientIdRow({ + required PlexAccountConnection replacement, + required String clientIdentifier, + required ConnectionRegistry connections, + required ProfileConnectionRegistry profileConnections, + required StorageService storage, +}) async { + final legacyId = 'plex.$clientIdentifier'; + if (legacyId == replacement.id) return; + final legacy = await connections.get(legacyId); + if (legacy is! PlexAccountConnection) return; + + final rows = await profileConnections.listForConnection(legacyId); + for (final row in rows) { + await profileConnections.upsert(row.copyWith(connectionId: replacement.id)); + } + await storage.clearPlexHomeUsersCache(legacyId); + // The FK cascade drops the legacy rows we just re-pointed copies of. + await connections.remove(legacyId); + appLogger.i('Migrated legacy Plex account row $legacyId → ${replacement.id}'); +} diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 0d7261b2..4ef05832 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Връзката е използвана.", "borrowFailed": "Неуспешно използване на връзка.", "incorrectPin": "Неправилен PIN.", + "incorrectPinTryAgain": "Неправилен PIN. Опитайте отново.", "sourceProfileMissingParentAccount": "Изходният профил няма родителски акаунт.", + "failedToLoadHomeUsers": "Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.", "failedToVerifyPin": "Неуспешна проверка на PIN.", "newProfile": "Нов профил", "profileNameHint": "напр. Гости, Деца, Семейна стая", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index a0224f4b..35037507 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Forbindelse lånt.", "borrowFailed": "Kunne ikke låne forbindelse.", "incorrectPin": "Forkert PIN.", + "incorrectPinTryAgain": "Forkert PIN. Prøv igen.", "sourceProfileMissingParentAccount": "Kildeprofilen mangler sin overordnede konto.", + "failedToLoadHomeUsers": "Kunne ikke indlæse dine Plex Home-brugere. Tjek din forbindelse, og prøv igen.", "failedToVerifyPin": "Kunne ikke bekræfte PIN.", "newProfile": "Ny profil", "profileNameHint": "fx. Gæster, Børn, Familiens stue", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index a07d3246..70cbcfda 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Verbindung ausgeliehen.", "borrowFailed": "Verbindung konnte nicht ausgeliehen werden.", "incorrectPin": "Falsche PIN.", + "incorrectPinTryAgain": "Falsche PIN. Bitte erneut versuchen.", "sourceProfileMissingParentAccount": "Dem Quellprofil fehlt das übergeordnete Konto.", + "failedToLoadHomeUsers": "Deine Plex Home-Benutzer konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", "failedToVerifyPin": "PIN konnte nicht verifiziert werden.", "newProfile": "Neues Profil", "profileNameHint": "z. B. Gäste, Kinder, Wohnzimmer", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 80cea061..6630d384 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -642,7 +642,9 @@ "borrowConnectionBorrowed": "Connection borrowed.", "borrowFailed": "Failed to borrow connection.", "incorrectPin": "Incorrect PIN.", + "incorrectPinTryAgain": "Incorrect PIN. Please try again.", "sourceProfileMissingParentAccount": "Source profile is missing its parent account.", + "failedToLoadHomeUsers": "Could not load your Plex Home users. Check your connection and try again.", "failedToVerifyPin": "Failed to verify PIN.", "newProfile": "New profile", "profileNameHint": "e.g. Guests, Kids, Family Room", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 473d7996..5ecca4a1 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Conexión tomada prestada.", "borrowFailed": "No se pudo tomar prestada la conexión.", "incorrectPin": "PIN incorrecto.", + "incorrectPinTryAgain": "PIN incorrecto. Inténtalo de nuevo.", "sourceProfileMissingParentAccount": "Al perfil de origen le falta su cuenta principal.", + "failedToLoadHomeUsers": "No se pudieron cargar tus usuarios de Plex Home. Comprueba tu conexión e inténtalo de nuevo.", "failedToVerifyPin": "No se pudo verificar el PIN.", "newProfile": "Nuevo perfil", "profileNameHint": "p. ej. Invitados, Niños, Sala familiar", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 590d65ad..4e776879 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Connexion empruntée.", "borrowFailed": "Impossible d'emprunter la connexion.", "incorrectPin": "PIN incorrect.", + "incorrectPinTryAgain": "PIN incorrect. Veuillez réessayer.", "sourceProfileMissingParentAccount": "Le profil source n'a pas son compte parent.", + "failedToLoadHomeUsers": "Impossible de charger vos utilisateurs Plex Home. Vérifiez votre connexion et réessayez.", "failedToVerifyPin": "Impossible de vérifier le PIN.", "newProfile": "Nouveau profil", "profileNameHint": "ex. Invités, Enfants, Salon familial", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index c65e0d7c..a4378d03 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Connessione presa in prestito.", "borrowFailed": "Impossibile prendere in prestito la connessione.", "incorrectPin": "PIN errato.", + "incorrectPinTryAgain": "PIN errato. Riprova.", "sourceProfileMissingParentAccount": "Al profilo di origine manca l'account principale.", + "failedToLoadHomeUsers": "Impossibile caricare gli utenti Plex Home. Controlla la connessione e riprova.", "failedToVerifyPin": "Impossibile verificare il PIN.", "newProfile": "Nuovo profilo", "profileNameHint": "es. Ospiti, Bambini, Soggiorno", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 1f4ab59b..58cbd99f 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "接続を借用しました。", "borrowFailed": "接続を借用できませんでした。", "incorrectPin": "PINが正しくありません。", + "incorrectPinTryAgain": "PINが正しくありません。もう一度お試しください。", "sourceProfileMissingParentAccount": "ソースプロフィールに親アカウントがありません。", + "failedToLoadHomeUsers": "Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。", "failedToVerifyPin": "PINを確認できませんでした。", "newProfile": "新しいプロファイル", "profileNameHint": "例:ゲスト、キッズ、ファミリールーム", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 460694a6..0a27e6a6 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "연결을 빌렸습니다.", "borrowFailed": "연결을 빌리지 못했습니다.", "incorrectPin": "PIN이 올바르지 않습니다.", + "incorrectPinTryAgain": "PIN이 올바르지 않습니다. 다시 시도하세요.", "sourceProfileMissingParentAccount": "원본 프로필에 상위 계정이 없습니다.", + "failedToLoadHomeUsers": "Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.", "failedToVerifyPin": "PIN을 확인하지 못했습니다.", "newProfile": "새 프로필", "profileNameHint": "예: 손님, 어린이, 가족실", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 698a7b42..f4766fe7 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Tilkobling lånt.", "borrowFailed": "Kunne ikke låne tilkoblingen.", "incorrectPin": "Feil PIN.", + "incorrectPinTryAgain": "Feil PIN. Prøv igjen.", "sourceProfileMissingParentAccount": "Kildeprofilen mangler foreldrekontoen sin.", + "failedToLoadHomeUsers": "Kunne ikke laste inn Plex Home-brukerne dine. Sjekk tilkoblingen og prøv igjen.", "failedToVerifyPin": "Kunne ikke bekrefte PIN.", "newProfile": "Ny profil", "profileNameHint": "f.eks. Gjester, Barn, Familierom", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 584f86ca..f07149fa 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Verbinding geleend.", "borrowFailed": "Kan verbinding niet lenen.", "incorrectPin": "Onjuiste PIN.", + "incorrectPinTryAgain": "Onjuiste PIN. Probeer het opnieuw.", "sourceProfileMissingParentAccount": "Het bronprofiel mist het bovenliggende account.", + "failedToLoadHomeUsers": "Kan je Plex Home-gebruikers niet laden. Controleer je verbinding en probeer het opnieuw.", "failedToVerifyPin": "Kan PIN niet verifiëren.", "newProfile": "Nieuw profiel", "profileNameHint": "bijv. Gasten, Kinderen, Woonkamer", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 95120a53..199928ce 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Połączenie pożyczone.", "borrowFailed": "Nie udało się pożyczyć połączenia.", "incorrectPin": "Nieprawidłowy PIN.", + "incorrectPinTryAgain": "Nieprawidłowy PIN. Spróbuj ponownie.", "sourceProfileMissingParentAccount": "Profil źródłowy nie ma konta nadrzędnego.", + "failedToLoadHomeUsers": "Nie udało się wczytać użytkowników Plex Home. Sprawdź połączenie i spróbuj ponownie.", "failedToVerifyPin": "Nie udało się zweryfikować PIN-u.", "newProfile": "Nowy profil", "profileNameHint": "np. Goście, Dzieci, Salon", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 485f8cd2..25f05f81 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Conexão tomada emprestada.", "borrowFailed": "Não foi possível tomar a conexão emprestada.", "incorrectPin": "PIN incorreto.", + "incorrectPinTryAgain": "PIN incorreto. Tente novamente.", "sourceProfileMissingParentAccount": "O perfil de origem não tem a conta principal.", + "failedToLoadHomeUsers": "Não foi possível carregar seus usuários do Plex Home. Verifique sua conexão e tente novamente.", "failedToVerifyPin": "Não foi possível verificar o PIN.", "newProfile": "Novo perfil", "profileNameHint": "ex.: Visitantes, Crianças, Sala de família", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index e4d9cb91..5a2d9b52 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Подключение заимствовано.", "borrowFailed": "Не удалось заимствовать подключение.", "incorrectPin": "Неверный PIN.", + "incorrectPinTryAgain": "Неверный PIN. Попробуйте ещё раз.", "sourceProfileMissingParentAccount": "У исходного профиля отсутствует родительская учетная запись.", + "failedToLoadHomeUsers": "Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.", "failedToVerifyPin": "Не удалось проверить PIN.", "newProfile": "Новый профиль", "profileNameHint": "например, Гости, Дети, Семейная комната", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 092b9cdd..a42dfb77 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20473 (1279 per locale) +/// Strings: 20505 (1281 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 037ea6f4..9737a47d 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesBg extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Връзката е използвана.'; @override String get borrowFailed => 'Неуспешно използване на връзка.'; @override String get incorrectPin => 'Неправилен PIN.'; + @override String get incorrectPinTryAgain => 'Неправилен PIN. Опитайте отново.'; @override String get sourceProfileMissingParentAccount => 'Изходният профил няма родителски акаунт.'; + @override String get failedToLoadHomeUsers => 'Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.'; @override String get failedToVerifyPin => 'Неуспешна проверка на PIN.'; @override String get newProfile => 'Нов профил'; @override String get profileNameHint => 'напр. Гости, Деца, Семейна стая'; @@ -2524,7 +2526,9 @@ extension on TranslationsBg { 'profiles.borrowConnectionBorrowed' => 'Връзката е използвана.', 'profiles.borrowFailed' => 'Неуспешно използване на връзка.', 'profiles.incorrectPin' => 'Неправилен PIN.', + 'profiles.incorrectPinTryAgain' => 'Неправилен PIN. Опитайте отново.', 'profiles.sourceProfileMissingParentAccount' => 'Изходният профил няма родителски акаунт.', + 'profiles.failedToLoadHomeUsers' => 'Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.', 'profiles.failedToVerifyPin' => 'Неуспешна проверка на PIN.', 'profiles.newProfile' => 'Нов профил', 'profiles.profileNameHint' => 'напр. Гости, Деца, Семейна стая', @@ -2952,10 +2956,10 @@ extension on TranslationsBg { 'companionRemote.pairing.validationHostRequired' => 'Моля, въведете адрес на хоста', 'companionRemote.pairing.validationHostFormat' => 'Форматът трябва да е IP:port (напр. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Връзката изтече. Използвайте една и съща мрежа на двете устройства.', - 'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.', - 'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.', + 'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?', 'companionRemote.remote.reconnecting' => 'Повторно свързване...', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 454eab55..4b366202 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesDa extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Forbindelse lånt.'; @override String get borrowFailed => 'Kunne ikke låne forbindelse.'; @override String get incorrectPin => 'Forkert PIN.'; + @override String get incorrectPinTryAgain => 'Forkert PIN. Prøv igen.'; @override String get sourceProfileMissingParentAccount => 'Kildeprofilen mangler sin overordnede konto.'; + @override String get failedToLoadHomeUsers => 'Kunne ikke indlæse dine Plex Home-brugere. Tjek din forbindelse, og prøv igen.'; @override String get failedToVerifyPin => 'Kunne ikke bekræfte PIN.'; @override String get newProfile => 'Ny profil'; @override String get profileNameHint => 'fx. Gæster, Børn, Familiens stue'; @@ -2524,7 +2526,9 @@ extension on TranslationsDa { 'profiles.borrowConnectionBorrowed' => 'Forbindelse lånt.', 'profiles.borrowFailed' => 'Kunne ikke låne forbindelse.', 'profiles.incorrectPin' => 'Forkert PIN.', + 'profiles.incorrectPinTryAgain' => 'Forkert PIN. Prøv igen.', 'profiles.sourceProfileMissingParentAccount' => 'Kildeprofilen mangler sin overordnede konto.', + 'profiles.failedToLoadHomeUsers' => 'Kunne ikke indlæse dine Plex Home-brugere. Tjek din forbindelse, og prøv igen.', 'profiles.failedToVerifyPin' => 'Kunne ikke bekræfte PIN.', 'profiles.newProfile' => 'Ny profil', 'profiles.profileNameHint' => 'fx. Gæster, Børn, Familiens stue', @@ -2952,10 +2956,10 @@ extension on TranslationsDa { 'companionRemote.pairing.validationHostRequired' => 'Angiv venligst værtsadresse', 'companionRemote.pairing.validationHostFormat' => 'Format skal være IP:port (f.eks. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Forbindelsen fik timeout. Brug samme netværk på begge enheder.', - 'companionRemote.pairing.sessionNotFound' => 'Enhed ikke fundet. Sørg for, at Plezy kører på værten.', - 'companionRemote.pairing.authFailed' => 'Godkendelse mislykkedes. Begge enheder skal bruge samme Plex-konto.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Enhed ikke fundet. Sørg for, at Plezy kører på værten.', + 'companionRemote.pairing.authFailed' => 'Godkendelse mislykkedes. Begge enheder skal bruge samme Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke oprette forbindelse: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde fra fjernsessionen?', 'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 6c73c5bb..bb90c998 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesDe extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Verbindung ausgeliehen.'; @override String get borrowFailed => 'Verbindung konnte nicht ausgeliehen werden.'; @override String get incorrectPin => 'Falsche PIN.'; + @override String get incorrectPinTryAgain => 'Falsche PIN. Bitte erneut versuchen.'; @override String get sourceProfileMissingParentAccount => 'Dem Quellprofil fehlt das übergeordnete Konto.'; + @override String get failedToLoadHomeUsers => 'Deine Plex Home-Benutzer konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.'; @override String get failedToVerifyPin => 'PIN konnte nicht verifiziert werden.'; @override String get newProfile => 'Neues Profil'; @override String get profileNameHint => 'z. B. Gäste, Kinder, Wohnzimmer'; @@ -2524,7 +2526,9 @@ extension on TranslationsDe { 'profiles.borrowConnectionBorrowed' => 'Verbindung ausgeliehen.', 'profiles.borrowFailed' => 'Verbindung konnte nicht ausgeliehen werden.', 'profiles.incorrectPin' => 'Falsche PIN.', + 'profiles.incorrectPinTryAgain' => 'Falsche PIN. Bitte erneut versuchen.', 'profiles.sourceProfileMissingParentAccount' => 'Dem Quellprofil fehlt das übergeordnete Konto.', + 'profiles.failedToLoadHomeUsers' => 'Deine Plex Home-Benutzer konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.', 'profiles.failedToVerifyPin' => 'PIN konnte nicht verifiziert werden.', 'profiles.newProfile' => 'Neues Profil', 'profiles.profileNameHint' => 'z. B. Gäste, Kinder, Wohnzimmer', @@ -2952,10 +2956,10 @@ extension on TranslationsDe { 'companionRemote.pairing.validationHostRequired' => 'Bitte Host-Adresse eingeben', 'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Verbindung hat Zeitlimit überschritten. Nutze auf beiden Geräten dasselbe Netzwerk.', - 'companionRemote.pairing.sessionNotFound' => 'Gerät nicht gefunden. Stelle sicher, dass Plezy auf dem Host läuft.', - 'companionRemote.pairing.authFailed' => 'Authentifizierung fehlgeschlagen. Beide Geräte benötigen dasselbe Plex-Konto.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Gerät nicht gefunden. Stelle sicher, dass Plezy auf dem Host läuft.', + 'companionRemote.pairing.authFailed' => 'Authentifizierung fehlgeschlagen. Beide Geräte benötigen dasselbe Plex-Konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?', 'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index d0d2ec68..69535866 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1975,9 +1975,15 @@ class TranslationsProfilesEn { /// en: 'Incorrect PIN.' String get incorrectPin => 'Incorrect PIN.'; + /// en: 'Incorrect PIN. Please try again.' + String get incorrectPinTryAgain => 'Incorrect PIN. Please try again.'; + /// en: 'Source profile is missing its parent account.' String get sourceProfileMissingParentAccount => 'Source profile is missing its parent account.'; + /// en: 'Could not load your Plex Home users. Check your connection and try again.' + String get failedToLoadHomeUsers => 'Could not load your Plex Home users. Check your connection and try again.'; + /// en: 'Failed to verify PIN.' String get failedToVerifyPin => 'Failed to verify PIN.'; @@ -5121,7 +5127,9 @@ extension on Translations { 'profiles.borrowConnectionBorrowed' => 'Connection borrowed.', 'profiles.borrowFailed' => 'Failed to borrow connection.', 'profiles.incorrectPin' => 'Incorrect PIN.', + 'profiles.incorrectPinTryAgain' => 'Incorrect PIN. Please try again.', 'profiles.sourceProfileMissingParentAccount' => 'Source profile is missing its parent account.', + 'profiles.failedToLoadHomeUsers' => 'Could not load your Plex Home users. Check your connection and try again.', 'profiles.failedToVerifyPin' => 'Failed to verify PIN.', 'profiles.newProfile' => 'New profile', 'profiles.profileNameHint' => 'e.g. Guests, Kids, Family Room', @@ -5543,10 +5551,10 @@ extension on Translations { 'companionRemote.pairing.discoveryDescription' => 'Plezy devices with the same Plex account appear here', 'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632', 'companionRemote.pairing.connecting' => 'Connecting...', - 'companionRemote.pairing.searchingForDevices' => 'Looking for devices...', - 'companionRemote.pairing.noDevicesFound' => 'No devices found on your network', _ => null, } ?? switch (path) { + 'companionRemote.pairing.searchingForDevices' => 'Looking for devices...', + 'companionRemote.pairing.noDevicesFound' => 'No devices found on your network', 'companionRemote.pairing.noDevicesHint' => 'Open Plezy on desktop and use the same WiFi', 'companionRemote.pairing.availableDevices' => 'Available Devices', 'companionRemote.pairing.manualConnection' => 'Manual Connection', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index fff76343..c5c7dad2 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesEs extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Conexión tomada prestada.'; @override String get borrowFailed => 'No se pudo tomar prestada la conexión.'; @override String get incorrectPin => 'PIN incorrecto.'; + @override String get incorrectPinTryAgain => 'PIN incorrecto. Inténtalo de nuevo.'; @override String get sourceProfileMissingParentAccount => 'Al perfil de origen le falta su cuenta principal.'; + @override String get failedToLoadHomeUsers => 'No se pudieron cargar tus usuarios de Plex Home. Comprueba tu conexión e inténtalo de nuevo.'; @override String get failedToVerifyPin => 'No se pudo verificar el PIN.'; @override String get newProfile => 'Nuevo perfil'; @override String get profileNameHint => 'p. ej. Invitados, Niños, Sala familiar'; @@ -2524,7 +2526,9 @@ extension on TranslationsEs { 'profiles.borrowConnectionBorrowed' => 'Conexión tomada prestada.', 'profiles.borrowFailed' => 'No se pudo tomar prestada la conexión.', 'profiles.incorrectPin' => 'PIN incorrecto.', + 'profiles.incorrectPinTryAgain' => 'PIN incorrecto. Inténtalo de nuevo.', 'profiles.sourceProfileMissingParentAccount' => 'Al perfil de origen le falta su cuenta principal.', + 'profiles.failedToLoadHomeUsers' => 'No se pudieron cargar tus usuarios de Plex Home. Comprueba tu conexión e inténtalo de nuevo.', 'profiles.failedToVerifyPin' => 'No se pudo verificar el PIN.', 'profiles.newProfile' => 'Nuevo perfil', 'profiles.profileNameHint' => 'p. ej. Invitados, Niños, Sala familiar', @@ -2952,10 +2956,10 @@ extension on TranslationsEs { 'companionRemote.pairing.validationHostRequired' => 'Ingresa la dirección del host', 'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (ej. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Tiempo de conexión agotado. Usa la misma red en ambos dispositivos.', - 'companionRemote.pairing.sessionNotFound' => 'Dispositivo no encontrado. Asegúrate de que Plezy esté en ejecución en el host.', - 'companionRemote.pairing.authFailed' => 'Autenticación fallida. Ambos dispositivos necesitan la misma cuenta Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Dispositivo no encontrado. Asegúrate de que Plezy esté en ejecución en el host.', + 'companionRemote.pairing.authFailed' => 'Autenticación fallida. Ambos dispositivos necesitan la misma cuenta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}', 'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?', 'companionRemote.remote.reconnecting' => 'Reconectando...', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 841abb99..1ca8b96a 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesFr extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Connexion empruntée.'; @override String get borrowFailed => 'Impossible d\'emprunter la connexion.'; @override String get incorrectPin => 'PIN incorrect.'; + @override String get incorrectPinTryAgain => 'PIN incorrect. Veuillez réessayer.'; @override String get sourceProfileMissingParentAccount => 'Le profil source n\'a pas son compte parent.'; + @override String get failedToLoadHomeUsers => 'Impossible de charger vos utilisateurs Plex Home. Vérifiez votre connexion et réessayez.'; @override String get failedToVerifyPin => 'Impossible de vérifier le PIN.'; @override String get newProfile => 'Nouveau profil'; @override String get profileNameHint => 'ex. Invités, Enfants, Salon familial'; @@ -2524,7 +2526,9 @@ extension on TranslationsFr { 'profiles.borrowConnectionBorrowed' => 'Connexion empruntée.', 'profiles.borrowFailed' => 'Impossible d\'emprunter la connexion.', 'profiles.incorrectPin' => 'PIN incorrect.', + 'profiles.incorrectPinTryAgain' => 'PIN incorrect. Veuillez réessayer.', 'profiles.sourceProfileMissingParentAccount' => 'Le profil source n\'a pas son compte parent.', + 'profiles.failedToLoadHomeUsers' => 'Impossible de charger vos utilisateurs Plex Home. Vérifiez votre connexion et réessayez.', 'profiles.failedToVerifyPin' => 'Impossible de vérifier le PIN.', 'profiles.newProfile' => 'Nouveau profil', 'profiles.profileNameHint' => 'ex. Invités, Enfants, Salon familial', @@ -2952,10 +2956,10 @@ extension on TranslationsFr { 'companionRemote.pairing.validationHostRequired' => 'Veuillez entrer l\'adresse de l\'hôte', 'companionRemote.pairing.validationHostFormat' => 'Le format doit être IP:port (ex. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Connexion expirée. Utilisez le même réseau sur les deux appareils.', - 'companionRemote.pairing.sessionNotFound' => 'Appareil introuvable. Assurez-vous que Plezy fonctionne sur l\'hôte.', - 'companionRemote.pairing.authFailed' => 'Échec de l\'authentification. Les deux appareils doivent utiliser le même compte Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Appareil introuvable. Assurez-vous que Plezy fonctionne sur l\'hôte.', + 'companionRemote.pairing.authFailed' => 'Échec de l\'authentification. Les deux appareils doivent utiliser le même compte Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}', 'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?', 'companionRemote.remote.reconnecting' => 'Reconnexion...', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index f4d15b32..ad09090b 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesIt extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Connessione presa in prestito.'; @override String get borrowFailed => 'Impossibile prendere in prestito la connessione.'; @override String get incorrectPin => 'PIN errato.'; + @override String get incorrectPinTryAgain => 'PIN errato. Riprova.'; @override String get sourceProfileMissingParentAccount => 'Al profilo di origine manca l\'account principale.'; + @override String get failedToLoadHomeUsers => 'Impossibile caricare gli utenti Plex Home. Controlla la connessione e riprova.'; @override String get failedToVerifyPin => 'Impossibile verificare il PIN.'; @override String get newProfile => 'Nuovo profilo'; @override String get profileNameHint => 'es. Ospiti, Bambini, Soggiorno'; @@ -2524,7 +2526,9 @@ extension on TranslationsIt { 'profiles.borrowConnectionBorrowed' => 'Connessione presa in prestito.', 'profiles.borrowFailed' => 'Impossibile prendere in prestito la connessione.', 'profiles.incorrectPin' => 'PIN errato.', + 'profiles.incorrectPinTryAgain' => 'PIN errato. Riprova.', 'profiles.sourceProfileMissingParentAccount' => 'Al profilo di origine manca l\'account principale.', + 'profiles.failedToLoadHomeUsers' => 'Impossibile caricare gli utenti Plex Home. Controlla la connessione e riprova.', 'profiles.failedToVerifyPin' => 'Impossibile verificare il PIN.', 'profiles.newProfile' => 'Nuovo profilo', 'profiles.profileNameHint' => 'es. Ospiti, Bambini, Soggiorno', @@ -2952,10 +2956,10 @@ extension on TranslationsIt { 'companionRemote.pairing.validationHostRequired' => 'Inserisci l\'indirizzo host', 'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Connessione scaduta. Usa la stessa rete su entrambi i dispositivi.', - 'companionRemote.pairing.sessionNotFound' => 'Dispositivo non trovato. Assicurati che Plezy sia in esecuzione sull\'host.', - 'companionRemote.pairing.authFailed' => 'Autenticazione non riuscita. Entrambi i dispositivi devono usare lo stesso account Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Dispositivo non trovato. Assicurati che Plezy sia in esecuzione sull\'host.', + 'companionRemote.pairing.authFailed' => 'Autenticazione non riuscita. Entrambi i dispositivi devono usare lo stesso account Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione fallita: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?', 'companionRemote.remote.reconnecting' => 'Riconnessione...', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index e497da3a..88c9ce1c 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesJa extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => '接続を借用しました。'; @override String get borrowFailed => '接続を借用できませんでした。'; @override String get incorrectPin => 'PINが正しくありません。'; + @override String get incorrectPinTryAgain => 'PINが正しくありません。もう一度お試しください。'; @override String get sourceProfileMissingParentAccount => 'ソースプロフィールに親アカウントがありません。'; + @override String get failedToLoadHomeUsers => 'Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。'; @override String get failedToVerifyPin => 'PINを確認できませんでした。'; @override String get newProfile => '新しいプロファイル'; @override String get profileNameHint => '例:ゲスト、キッズ、ファミリールーム'; @@ -2524,7 +2526,9 @@ extension on TranslationsJa { 'profiles.borrowConnectionBorrowed' => '接続を借用しました。', 'profiles.borrowFailed' => '接続を借用できませんでした。', 'profiles.incorrectPin' => 'PINが正しくありません。', + 'profiles.incorrectPinTryAgain' => 'PINが正しくありません。もう一度お試しください。', 'profiles.sourceProfileMissingParentAccount' => 'ソースプロフィールに親アカウントがありません。', + 'profiles.failedToLoadHomeUsers' => 'Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。', 'profiles.failedToVerifyPin' => 'PINを確認できませんでした。', 'profiles.newProfile' => '新しいプロファイル', 'profiles.profileNameHint' => '例:ゲスト、キッズ、ファミリールーム', @@ -2952,10 +2956,10 @@ extension on TranslationsJa { 'companionRemote.pairing.validationHostRequired' => 'ホストアドレスを入力してください', 'companionRemote.pairing.validationHostFormat' => '形式はIP:ポートである必要があります(例: 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '接続がタイムアウトしました。両方のデバイスで同じネットワークを使用してください。', - 'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。', - 'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。', + 'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}', 'companionRemote.remote.disconnectConfirm' => 'リモートセッションから切断しますか?', 'companionRemote.remote.reconnecting' => '再接続中...', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index f6f704cf..b598535a 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesKo extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => '연결을 빌렸습니다.'; @override String get borrowFailed => '연결을 빌리지 못했습니다.'; @override String get incorrectPin => 'PIN이 올바르지 않습니다.'; + @override String get incorrectPinTryAgain => 'PIN이 올바르지 않습니다. 다시 시도하세요.'; @override String get sourceProfileMissingParentAccount => '원본 프로필에 상위 계정이 없습니다.'; + @override String get failedToLoadHomeUsers => 'Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.'; @override String get failedToVerifyPin => 'PIN을 확인하지 못했습니다.'; @override String get newProfile => '새 프로필'; @override String get profileNameHint => '예: 손님, 어린이, 가족실'; @@ -2524,7 +2526,9 @@ extension on TranslationsKo { 'profiles.borrowConnectionBorrowed' => '연결을 빌렸습니다.', 'profiles.borrowFailed' => '연결을 빌리지 못했습니다.', 'profiles.incorrectPin' => 'PIN이 올바르지 않습니다.', + 'profiles.incorrectPinTryAgain' => 'PIN이 올바르지 않습니다. 다시 시도하세요.', 'profiles.sourceProfileMissingParentAccount' => '원본 프로필에 상위 계정이 없습니다.', + 'profiles.failedToLoadHomeUsers' => 'Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.', 'profiles.failedToVerifyPin' => 'PIN을 확인하지 못했습니다.', 'profiles.newProfile' => '새 프로필', 'profiles.profileNameHint' => '예: 손님, 어린이, 가족실', @@ -2952,10 +2956,10 @@ extension on TranslationsKo { 'companionRemote.pairing.validationHostRequired' => '호스트 주소를 입력하세요', 'companionRemote.pairing.validationHostFormat' => '형식은 IP:포트여야 합니다 (예: 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 두 기기에서 같은 네트워크를 사용하세요.', - 'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.', - 'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.', + 'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}', 'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?', 'companionRemote.remote.reconnecting' => '재연결 중...', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 8b36900c..ffdb5a7e 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesNb extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Tilkobling lånt.'; @override String get borrowFailed => 'Kunne ikke låne tilkoblingen.'; @override String get incorrectPin => 'Feil PIN.'; + @override String get incorrectPinTryAgain => 'Feil PIN. Prøv igjen.'; @override String get sourceProfileMissingParentAccount => 'Kildeprofilen mangler foreldrekontoen sin.'; + @override String get failedToLoadHomeUsers => 'Kunne ikke laste inn Plex Home-brukerne dine. Sjekk tilkoblingen og prøv igjen.'; @override String get failedToVerifyPin => 'Kunne ikke bekrefte PIN.'; @override String get newProfile => 'Ny profil'; @override String get profileNameHint => 'f.eks. Gjester, Barn, Familierom'; @@ -2524,7 +2526,9 @@ extension on TranslationsNb { 'profiles.borrowConnectionBorrowed' => 'Tilkobling lånt.', 'profiles.borrowFailed' => 'Kunne ikke låne tilkoblingen.', 'profiles.incorrectPin' => 'Feil PIN.', + 'profiles.incorrectPinTryAgain' => 'Feil PIN. Prøv igjen.', 'profiles.sourceProfileMissingParentAccount' => 'Kildeprofilen mangler foreldrekontoen sin.', + 'profiles.failedToLoadHomeUsers' => 'Kunne ikke laste inn Plex Home-brukerne dine. Sjekk tilkoblingen og prøv igjen.', 'profiles.failedToVerifyPin' => 'Kunne ikke bekrefte PIN.', 'profiles.newProfile' => 'Ny profil', 'profiles.profileNameHint' => 'f.eks. Gjester, Barn, Familierom', @@ -2952,10 +2956,10 @@ extension on TranslationsNb { 'companionRemote.pairing.validationHostRequired' => 'Vennligst oppgi vertsadresse', 'companionRemote.pairing.validationHostFormat' => 'Format må være IP:port (f.eks. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Tilkoblingen fikk tidsavbrudd. Bruk samme nettverk på begge enheter.', - 'companionRemote.pairing.sessionNotFound' => 'Enhet ikke funnet. Sørg for at Plezy kjører på verten.', - 'companionRemote.pairing.authFailed' => 'Autentisering mislyktes. Begge enheter må bruke samme Plex-konto.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Enhet ikke funnet. Sørg for at Plezy kjører på verten.', + 'companionRemote.pairing.authFailed' => 'Autentisering mislyktes. Begge enheter må bruke samme Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke koble til: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?', 'companionRemote.remote.reconnecting' => 'Kobler til på nytt...', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 583242e5..c6bd6b00 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesNl extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Verbinding geleend.'; @override String get borrowFailed => 'Kan verbinding niet lenen.'; @override String get incorrectPin => 'Onjuiste PIN.'; + @override String get incorrectPinTryAgain => 'Onjuiste PIN. Probeer het opnieuw.'; @override String get sourceProfileMissingParentAccount => 'Het bronprofiel mist het bovenliggende account.'; + @override String get failedToLoadHomeUsers => 'Kan je Plex Home-gebruikers niet laden. Controleer je verbinding en probeer het opnieuw.'; @override String get failedToVerifyPin => 'Kan PIN niet verifiëren.'; @override String get newProfile => 'Nieuw profiel'; @override String get profileNameHint => 'bijv. Gasten, Kinderen, Woonkamer'; @@ -2524,7 +2526,9 @@ extension on TranslationsNl { 'profiles.borrowConnectionBorrowed' => 'Verbinding geleend.', 'profiles.borrowFailed' => 'Kan verbinding niet lenen.', 'profiles.incorrectPin' => 'Onjuiste PIN.', + 'profiles.incorrectPinTryAgain' => 'Onjuiste PIN. Probeer het opnieuw.', 'profiles.sourceProfileMissingParentAccount' => 'Het bronprofiel mist het bovenliggende account.', + 'profiles.failedToLoadHomeUsers' => 'Kan je Plex Home-gebruikers niet laden. Controleer je verbinding en probeer het opnieuw.', 'profiles.failedToVerifyPin' => 'Kan PIN niet verifiëren.', 'profiles.newProfile' => 'Nieuw profiel', 'profiles.profileNameHint' => 'bijv. Gasten, Kinderen, Woonkamer', @@ -2952,10 +2956,10 @@ extension on TranslationsNl { 'companionRemote.pairing.validationHostRequired' => 'Voer het hostadres in', 'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Verbinding verlopen. Gebruik hetzelfde netwerk op beide apparaten.', - 'companionRemote.pairing.sessionNotFound' => 'Apparaat niet gevonden. Zorg dat Plezy op de host draait.', - 'companionRemote.pairing.authFailed' => 'Authenticatie mislukt. Beide apparaten hebben hetzelfde Plex-account nodig.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Apparaat niet gevonden. Zorg dat Plezy op de host draait.', + 'companionRemote.pairing.authFailed' => 'Authenticatie mislukt. Beide apparaten hebben hetzelfde Plex-account nodig.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kan niet verbinden: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?', 'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 9253133b..a3b5099c 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesPl extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Połączenie pożyczone.'; @override String get borrowFailed => 'Nie udało się pożyczyć połączenia.'; @override String get incorrectPin => 'Nieprawidłowy PIN.'; + @override String get incorrectPinTryAgain => 'Nieprawidłowy PIN. Spróbuj ponownie.'; @override String get sourceProfileMissingParentAccount => 'Profil źródłowy nie ma konta nadrzędnego.'; + @override String get failedToLoadHomeUsers => 'Nie udało się wczytać użytkowników Plex Home. Sprawdź połączenie i spróbuj ponownie.'; @override String get failedToVerifyPin => 'Nie udało się zweryfikować PIN-u.'; @override String get newProfile => 'Nowy profil'; @override String get profileNameHint => 'np. Goście, Dzieci, Salon'; @@ -2524,7 +2526,9 @@ extension on TranslationsPl { 'profiles.borrowConnectionBorrowed' => 'Połączenie pożyczone.', 'profiles.borrowFailed' => 'Nie udało się pożyczyć połączenia.', 'profiles.incorrectPin' => 'Nieprawidłowy PIN.', + 'profiles.incorrectPinTryAgain' => 'Nieprawidłowy PIN. Spróbuj ponownie.', 'profiles.sourceProfileMissingParentAccount' => 'Profil źródłowy nie ma konta nadrzędnego.', + 'profiles.failedToLoadHomeUsers' => 'Nie udało się wczytać użytkowników Plex Home. Sprawdź połączenie i spróbuj ponownie.', 'profiles.failedToVerifyPin' => 'Nie udało się zweryfikować PIN-u.', 'profiles.newProfile' => 'Nowy profil', 'profiles.profileNameHint' => 'np. Goście, Dzieci, Salon', @@ -2952,10 +2956,10 @@ extension on TranslationsPl { 'companionRemote.pairing.validationHostRequired' => 'Wprowadź adres hosta', 'companionRemote.pairing.validationHostFormat' => 'Format musi być IP:port (np. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Limit czasu połączenia. Użyj tej samej sieci na obu urządzeniach.', - 'companionRemote.pairing.sessionNotFound' => 'Nie znaleziono urządzenia. Upewnij się, że Plezy działa na hoście.', - 'companionRemote.pairing.authFailed' => 'Uwierzytelnianie nie powiodło się. Oba urządzenia muszą używać tego samego konta Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Nie znaleziono urządzenia. Upewnij się, że Plezy działa na hoście.', + 'companionRemote.pairing.authFailed' => 'Uwierzytelnianie nie powiodło się. Oba urządzenia muszą używać tego samego konta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Nie udało się połączyć: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Czy chcesz się rozłączyć od sesji zdalnej?', 'companionRemote.remote.reconnecting' => 'Ponowne łączenie...', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 5ec150c9..64714522 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesPt extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Conexão tomada emprestada.'; @override String get borrowFailed => 'Não foi possível tomar a conexão emprestada.'; @override String get incorrectPin => 'PIN incorreto.'; + @override String get incorrectPinTryAgain => 'PIN incorreto. Tente novamente.'; @override String get sourceProfileMissingParentAccount => 'O perfil de origem não tem a conta principal.'; + @override String get failedToLoadHomeUsers => 'Não foi possível carregar seus usuários do Plex Home. Verifique sua conexão e tente novamente.'; @override String get failedToVerifyPin => 'Não foi possível verificar o PIN.'; @override String get newProfile => 'Novo perfil'; @override String get profileNameHint => 'ex.: Visitantes, Crianças, Sala de família'; @@ -2524,7 +2526,9 @@ extension on TranslationsPt { 'profiles.borrowConnectionBorrowed' => 'Conexão tomada emprestada.', 'profiles.borrowFailed' => 'Não foi possível tomar a conexão emprestada.', 'profiles.incorrectPin' => 'PIN incorreto.', + 'profiles.incorrectPinTryAgain' => 'PIN incorreto. Tente novamente.', 'profiles.sourceProfileMissingParentAccount' => 'O perfil de origem não tem a conta principal.', + 'profiles.failedToLoadHomeUsers' => 'Não foi possível carregar seus usuários do Plex Home. Verifique sua conexão e tente novamente.', 'profiles.failedToVerifyPin' => 'Não foi possível verificar o PIN.', 'profiles.newProfile' => 'Novo perfil', 'profiles.profileNameHint' => 'ex.: Visitantes, Crianças, Sala de família', @@ -2952,10 +2956,10 @@ extension on TranslationsPt { 'companionRemote.pairing.validationHostRequired' => 'Introduza o endereço do host', 'companionRemote.pairing.validationHostFormat' => 'O formato deve ser IP:porta (ex. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Conexão expirou. Use a mesma rede nos dois dispositivos.', - 'companionRemote.pairing.sessionNotFound' => 'Dispositivo não encontrado. Verifique se Plezy está rodando no host.', - 'companionRemote.pairing.authFailed' => 'Falha na autenticação. Ambos os dispositivos precisam da mesma conta Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Dispositivo não encontrado. Verifique se Plezy está rodando no host.', + 'companionRemote.pairing.authFailed' => 'Falha na autenticação. Ambos os dispositivos precisam da mesma conta Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Falha ao conectar: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?', 'companionRemote.remote.reconnecting' => 'Reconectando...', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 772a65b6..621cffe3 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesRu extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Подключение заимствовано.'; @override String get borrowFailed => 'Не удалось заимствовать подключение.'; @override String get incorrectPin => 'Неверный PIN.'; + @override String get incorrectPinTryAgain => 'Неверный PIN. Попробуйте ещё раз.'; @override String get sourceProfileMissingParentAccount => 'У исходного профиля отсутствует родительская учетная запись.'; + @override String get failedToLoadHomeUsers => 'Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.'; @override String get failedToVerifyPin => 'Не удалось проверить PIN.'; @override String get newProfile => 'Новый профиль'; @override String get profileNameHint => 'например, Гости, Дети, Семейная комната'; @@ -2524,7 +2526,9 @@ extension on TranslationsRu { 'profiles.borrowConnectionBorrowed' => 'Подключение заимствовано.', 'profiles.borrowFailed' => 'Не удалось заимствовать подключение.', 'profiles.incorrectPin' => 'Неверный PIN.', + 'profiles.incorrectPinTryAgain' => 'Неверный PIN. Попробуйте ещё раз.', 'profiles.sourceProfileMissingParentAccount' => 'У исходного профиля отсутствует родительская учетная запись.', + 'profiles.failedToLoadHomeUsers' => 'Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.', 'profiles.failedToVerifyPin' => 'Не удалось проверить PIN.', 'profiles.newProfile' => 'Новый профиль', 'profiles.profileNameHint' => 'например, Гости, Дети, Семейная комната', @@ -2952,10 +2956,10 @@ extension on TranslationsRu { 'companionRemote.pairing.validationHostRequired' => 'Введите адрес хоста', 'companionRemote.pairing.validationHostFormat' => 'Формат должен быть IP:порт (например, 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Время подключения истекло. Используйте одну сеть на обоих устройствах.', - 'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.', - 'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.', + 'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?', 'companionRemote.remote.reconnecting' => 'Переподключение...', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index c8c6a555..cd50a40b 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesSv extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => 'Anslutning lånad.'; @override String get borrowFailed => 'Kunde inte låna anslutningen.'; @override String get incorrectPin => 'Fel PIN.'; + @override String get incorrectPinTryAgain => 'Fel PIN. Försök igen.'; @override String get sourceProfileMissingParentAccount => 'Källprofilen saknar sitt överordnade konto.'; + @override String get failedToLoadHomeUsers => 'Kunde inte läsa in dina Plex Home-användare. Kontrollera anslutningen och försök igen.'; @override String get failedToVerifyPin => 'Kunde inte verifiera PIN.'; @override String get newProfile => 'Ny profil'; @override String get profileNameHint => 't.ex. Gäster, Barn, Familjerum'; @@ -2524,7 +2526,9 @@ extension on TranslationsSv { 'profiles.borrowConnectionBorrowed' => 'Anslutning lånad.', 'profiles.borrowFailed' => 'Kunde inte låna anslutningen.', 'profiles.incorrectPin' => 'Fel PIN.', + 'profiles.incorrectPinTryAgain' => 'Fel PIN. Försök igen.', 'profiles.sourceProfileMissingParentAccount' => 'Källprofilen saknar sitt överordnade konto.', + 'profiles.failedToLoadHomeUsers' => 'Kunde inte läsa in dina Plex Home-användare. Kontrollera anslutningen och försök igen.', 'profiles.failedToVerifyPin' => 'Kunde inte verifiera PIN.', 'profiles.newProfile' => 'Ny profil', 'profiles.profileNameHint' => 't.ex. Gäster, Barn, Familjerum', @@ -2952,10 +2956,10 @@ extension on TranslationsSv { 'companionRemote.pairing.validationHostRequired' => 'Ange värdadress', 'companionRemote.pairing.validationHostFormat' => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => 'Anslutningen tog för lång tid. Använd samma nätverk på båda enheter.', - 'companionRemote.pairing.sessionNotFound' => 'Enhet hittades inte. Kontrollera att Plezy körs på värden.', - 'companionRemote.pairing.authFailed' => 'Autentisering misslyckades. Båda enheter behöver samma Plex-konto.', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => 'Enhet hittades inte. Kontrollera att Plezy körs på värden.', + 'companionRemote.pairing.authFailed' => 'Autentisering misslyckades. Båda enheter behöver samma Plex-konto.', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}', 'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?', 'companionRemote.remote.reconnecting' => 'Återansluter...', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index b790be2b..13e9063b 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -829,7 +829,9 @@ class _TranslationsProfilesZh extends TranslationsProfilesEn { @override String get borrowConnectionBorrowed => '已借用连接。'; @override String get borrowFailed => '无法借用连接。'; @override String get incorrectPin => 'PIN 不正确。'; + @override String get incorrectPinTryAgain => 'PIN 不正确。请重试。'; @override String get sourceProfileMissingParentAccount => '源个人资料缺少其父账号。'; + @override String get failedToLoadHomeUsers => '无法加载您的 Plex Home 用户。请检查网络连接后重试。'; @override String get failedToVerifyPin => '无法验证 PIN。'; @override String get newProfile => '新建配置文件'; @override String get profileNameHint => '例如:访客、儿童、家庭房'; @@ -2524,7 +2526,9 @@ extension on TranslationsZh { 'profiles.borrowConnectionBorrowed' => '已借用连接。', 'profiles.borrowFailed' => '无法借用连接。', 'profiles.incorrectPin' => 'PIN 不正确。', + 'profiles.incorrectPinTryAgain' => 'PIN 不正确。请重试。', 'profiles.sourceProfileMissingParentAccount' => '源个人资料缺少其父账号。', + 'profiles.failedToLoadHomeUsers' => '无法加载您的 Plex Home 用户。请检查网络连接后重试。', 'profiles.failedToVerifyPin' => '无法验证 PIN。', 'profiles.newProfile' => '新建配置文件', 'profiles.profileNameHint' => '例如:访客、儿童、家庭房', @@ -2952,10 +2956,10 @@ extension on TranslationsZh { 'companionRemote.pairing.validationHostRequired' => '请输入主机地址', 'companionRemote.pairing.validationHostFormat' => '格式必须为IP:端口(例如 192.168.1.100:48632)', 'companionRemote.pairing.connectionTimedOut' => '连接超时。请在两台设备上使用同一网络。', - 'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。', - 'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。', _ => null, } ?? switch (path) { + 'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。', + 'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}', 'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?', 'companionRemote.remote.reconnecting' => '重新连接中...', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 03042259..aa15165b 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "Anslutning lånad.", "borrowFailed": "Kunde inte låna anslutningen.", "incorrectPin": "Fel PIN.", + "incorrectPinTryAgain": "Fel PIN. Försök igen.", "sourceProfileMissingParentAccount": "Källprofilen saknar sitt överordnade konto.", + "failedToLoadHomeUsers": "Kunde inte läsa in dina Plex Home-användare. Kontrollera anslutningen och försök igen.", "failedToVerifyPin": "Kunde inte verifiera PIN.", "newProfile": "Ny profil", "profileNameHint": "t.ex. Gäster, Barn, Familjerum", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 30b33d17..2e7881ee 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -636,7 +636,9 @@ "borrowConnectionBorrowed": "已借用连接。", "borrowFailed": "无法借用连接。", "incorrectPin": "PIN 不正确。", + "incorrectPinTryAgain": "PIN 不正确。请重试。", "sourceProfileMissingParentAccount": "源个人资料缺少其父账号。", + "failedToLoadHomeUsers": "无法加载您的 Plex Home 用户。请检查网络连接后重试。", "failedToVerifyPin": "无法验证 PIN。", "newProfile": "新建配置文件", "profileNameHint": "例如:访客、儿童、家庭房", diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index 15dcd8bd..a57a3fdb 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -168,12 +168,15 @@ class PlexHomeService { } /// Force-refresh a single account. Useful after sign-in / borrow flows. - Future refresh(PlexAccountConnection conn) => _fetchAndCache(conn); + /// Returns whether the fetch succeeded (callers that REQUIRE home users — + /// e.g. first sign-in, which can't build any profile without them — must + /// not conflate a failed fetch with "no users"). + Future refresh(PlexAccountConnection conn) => _fetchAndCache(conn); - Future _fetchAndCache(PlexAccountConnection conn) async { + Future _fetchAndCache(PlexAccountConnection conn) async { if (conn.accountToken.isEmpty) { appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token'); - return; + return false; } final storage = _storage ?? await StorageService.getInstance(); _storage = storage; @@ -184,7 +187,7 @@ class PlexHomeService { // as ghosts until the next removal event. if (await _connections.get(conn.id) == null) { appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}'); - return; + return false; } final encoded = users.map((u) => u.toJson()).toList(); // Unchanged fetches (the hourly ticker, mostly) must not emit: every @@ -192,14 +195,16 @@ class PlexHomeService { // recompute/notify cascade across the app. if (_byConnection.containsKey(conn.id) && storage.getPlexHomeUsersCacheJson(conn.id) == jsonEncode(encoded)) { appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}'); - return; + return true; } _byConnection[conn.id] = users; await storage.savePlexHomeUsersCache(conn.id, encoded); _emit(); appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}'); + return true; } catch (e, st) { appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: st); + return false; } } diff --git a/lib/profiles/plex_home_switch.dart b/lib/profiles/plex_home_switch.dart index d57b9385..1f101fa4 100644 --- a/lib/profiles/plex_home_switch.dart +++ b/lib/profiles/plex_home_switch.dart @@ -1,6 +1,10 @@ +import '../connection/connection.dart'; import '../exceptions/media_server_exceptions.dart'; +import '../i18n/strings.g.dart'; import '../services/plex_auth_service.dart'; import '../utils/app_logger.dart'; +import 'profile_connection.dart'; +import 'profile_connection_registry.dart'; /// Outcome of a Plex Home user switch attempt. enum PlexHomeSwitchStatus { success, cancelled, failed } @@ -51,7 +55,7 @@ Future switchPlexHomeUserWithPin({ return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); } on MediaServerHttpException catch (e) { if (e.statusCode == 403 && _isInvalidPin(e)) { - error = 'Incorrect PIN. Please try again.'; + error = t.profiles.incorrectPinTryAgain; pin = null; // Force the next iteration to prompt even when the caller didn't // expect a PIN — Plex disagrees about whether one is required. @@ -71,6 +75,52 @@ Future switchPlexHomeUserWithPin({ } } +/// One-shot UI-side mint of a Plex Home user-token: create a +/// [PlexAuthService], run [switchPlexHomeUserWithPin], optionally persist +/// the minted token as the ([persistProfileId], account) [ProfileConnection] +/// row, and dispose the service. +/// +/// This is the flow every UI surface needs (pre-verify on activation, +/// borrow, source-PIN validation); hand-rolling it drifts on persistence +/// and lifecycle. [ActiveProfileBinder] deliberately does NOT use this — +/// it owns a long-lived auth service. +Future mintPlexHomeUserToken({ + required PlexAccountConnection account, + required String homeUserUuid, + required bool requiresPin, + required PlexHomeSwitchPinPrompt promptForPin, + ProfileConnectionRegistry? persistTo, + String? persistProfileId, + String? logLabel, +}) async { + assert((persistTo == null) == (persistProfileId == null), 'persistTo and persistProfileId go together'); + final auth = await PlexAuthService.create(); + try { + final result = await switchPlexHomeUserWithPin( + auth: auth, + accountToken: account.accountToken, + homeUserUuid: homeUserUuid, + requiresPin: requiresPin, + promptForPin: promptForPin, + logLabel: logLabel, + ); + if (result.succeeded && persistTo != null && persistProfileId != null) { + await persistTo.upsert( + ProfileConnection( + profileId: persistProfileId, + connectionId: account.id, + userToken: result.userToken, + userIdentifier: homeUserUuid, + tokenAcquiredAt: DateTime.now(), + ), + ); + } + return result; + } finally { + auth.dispose(); + } +} + bool _isInvalidPin(MediaServerHttpException e) { final data = e.responseData; if (data is! Map) return false; diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index f340eb9b..b151b438 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -5,19 +5,20 @@ import '../connection/connection.dart'; import '../connection/connection_registry.dart'; import '../i18n/strings.g.dart'; import '../screens/profile/pin_entry_dialog.dart'; -import '../services/plex_auth_service.dart'; import '../utils/snackbar_helper.dart'; import 'active_profile_binder.dart'; import 'active_profile_provider.dart'; import 'plex_home_switch.dart'; import 'profile.dart'; -import 'profile_connection.dart'; import 'profile_connection_registry.dart'; +/// How a UI-driven activation attempt ended. `cancelled` (user backed out +/// of a PIN dialog) is not an error and must not surface a failure message. +enum ProfileActivationOutcome { activated, cancelled, failed } + /// Activate [profile] from a UI surface, prompting for the PIN when the -/// profile is protected. Returns `true` on successful activation, `false` -/// when the user cancelled the PIN dialog. Loops on wrong-PIN entries -/// until the user submits the right PIN or backs out. +/// profile is protected. Loops on wrong-PIN entries until the user submits +/// the right PIN or backs out. /// /// The retry loop uses the same shake-on-error pattern as Plex Home users /// — see [showPinEntryDialog]. @@ -27,48 +28,60 @@ import 'profile_connection_registry.dart'; /// failed PIN never flips `_active`. The minted user-token is saved and /// the profile is marked pre-verified on the binder, so it reuses the cached /// token instead of re-prompting for the same PIN. -Future activateProfileWithPin(BuildContext context, Profile profile) async { +Future activateProfileWithPin(BuildContext context, Profile profile) async { final active = context.read(); final binder = context.read(); if (profile.isPlexHome) { if (profile.plexProtected) { - final ok = await _preVerifyPlexHomePin(context, profile); - if (!ok) return false; + final verified = await _preVerifyPlexHomePin(context, profile); + if (verified != PlexHomeSwitchStatus.success) { + return verified == PlexHomeSwitchStatus.cancelled + ? ProfileActivationOutcome.cancelled + : ProfileActivationOutcome.failed; + } } binder.markUserInitiatedActivation(profile.id); - return active.activate(profile); + return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed; } if (!profile.isPinProtected) { binder.markUserInitiatedActivation(profile.id); - return active.activate(profile); + return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed; } String? errorMessage; while (true) { - if (!context.mounted) return false; + if (!context.mounted) return ProfileActivationOutcome.cancelled; final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); - if (pin == null) return false; // user cancelled + if (pin == null) return ProfileActivationOutcome.cancelled; // user backed out final hash = profile.pinHash; if (hash != null && verifyPin(pin, hash)) { binder.markUserInitiatedActivation(profile.id); - return active.activate(profile, pin: pin); + return await active.activate(profile, pin: pin) + ? ProfileActivationOutcome.activated + : ProfileActivationOutcome.failed; } - errorMessage = 'Incorrect PIN. Please try again.'; + errorMessage = t.profiles.incorrectPinTryAgain; } } /// Activate [profile] from a UI surface, then wait until the active profile's /// server/token binding has settled. Shows the standard switch failure message -/// for both activation and binding failures. +/// for activation and binding failures — but not for a PIN-dialog cancel, +/// which is the user changing their mind, not an error. Future switchProfileFromUi(BuildContext context, Profile profile) async { final activeProvider = context.read(); - final ok = await activateProfileWithPin(context, profile); + final outcome = await activateProfileWithPin(context, profile); if (!context.mounted) return false; - if (!ok) { - showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); - return false; + switch (outcome) { + case ProfileActivationOutcome.cancelled: + return false; + case ProfileActivationOutcome.failed: + showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); + return false; + case ProfileActivationOutcome.activated: + break; } final bound = await activeProvider.awaitBindingSettle(); @@ -83,20 +96,21 @@ Future switchProfileFromUi(BuildContext context, Profile profile) async { /// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On /// success, persist the minted user-token and mark the profile as /// pre-verified so [ActiveProfileBinder] reuses the cached token instead -/// of re-prompting. Returns `false` on cancel or a final failure (caller -/// must abort activation in that case). +/// of re-prompting. /// -/// Returns `true` without doing anything when the profile lacks the -/// parent/uuid metadata or the parent connection is missing — the -/// binder's existing missing-metadata path will fire and silently -/// produce an empty bind, matching today's behavior. We don't want to -/// fail activation outright for users in unusual data states. -Future _preVerifyPlexHomePin(BuildContext context, Profile profile) async { +/// Returns [PlexHomeSwitchStatus.success] without doing anything when the +/// profile lacks the parent/uuid metadata or the parent connection is +/// missing — the binder's existing missing-metadata path will fire and +/// silently produce an empty bind, matching today's behavior. We don't +/// want to fail activation outright for users in unusual data states. +Future _preVerifyPlexHomePin(BuildContext context, Profile profile) async { final parentId = profile.parentConnectionId; final homeUuid = profile.plexHomeUserUuid; - if (parentId == null || homeUuid == null) return true; + if (parentId == null || homeUuid == null) return PlexHomeSwitchStatus.success; final connections = context.read(); + final pcRegistry = context.read(); + final binder = context.read(); final all = await connections.list(); PlexAccountConnection? account; for (final c in all) { @@ -105,39 +119,22 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile profile) async break; } } - if (account == null) return true; + if (account == null) return PlexHomeSwitchStatus.success; - final auth = await PlexAuthService.create(); - try { - final result = await switchPlexHomeUserWithPin( - auth: auth, - accountToken: account.accountToken, - homeUserUuid: homeUuid, - requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!context.mounted) return null; - return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); - }, - logLabel: profile.displayName, - ); - if (!result.succeeded) return false; - if (!context.mounted) return false; - final pcRegistry = context.read(); - await pcRegistry.upsert( - ProfileConnection( - profileId: profile.id, - connectionId: account.id, - userToken: result.userToken, - userIdentifier: homeUuid, - tokenAcquiredAt: DateTime.now(), - ), - ); - if (!context.mounted) return false; - context.read().markPlexHomePreVerified(profile.id); - return true; - } finally { - auth.dispose(); - } + final result = await mintPlexHomeUserToken( + account: account, + homeUserUuid: homeUuid, + requiresPin: true, + promptForPin: ({String? errorMessage}) async { + if (!context.mounted) return null; + return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); + }, + persistTo: pcRegistry, + persistProfileId: profile.id, + logLabel: profile.displayName, + ); + if (result.succeeded) binder.markPlexHomePreVerified(profile.id); + return result.status; } /// Verify [pin] against [profile]'s stored PIN hash *without* activating it. diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 84208b22..a6acdc24 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../connection/connection.dart'; import '../connection/connection_registry.dart'; +import '../connection/plex_account_setup.dart'; import '../mixins/controller_disposer_mixin.dart'; import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../profiles/profile.dart'; +import '../profiles/profile_connection_registry.dart'; import '../services/plex_auth_service.dart'; import '../services/settings_service.dart'; import '../services/storage_service.dart'; @@ -45,7 +47,9 @@ class _AuthScreenState extends State { @override void initState() { super.initState(); - unawaited(_initVerifyService()); + // Debug-token verification only — release builds must not hold an idle + // auth service (and its HTTP client) for a dialog that can't open. + if (kDebugMode) unawaited(_initVerifyService()); } Future _initVerifyService() async { @@ -96,44 +100,47 @@ class _AuthScreenState extends State { }); final connectionRegistry = context.read(); + final profileConnections = context.read(); final plexHome = context.read(); - final svc = await PlexAuthService.create(); try { - final userInfo = await svc.getUserInfo(plexToken); - final username = userInfo['username'] as String? ?? ''; - final email = userInfo['email'] as String? ?? ''; - final accountUuid = (userInfo['uuid'] as String?)?.trim() ?? ''; - - final servers = await svc.fetchServers(plexToken); final storage = await StorageService.getInstance(); + final registration = await registerPlexAccountFromToken( + token: plexToken, + connections: connectionRegistry, + profileConnections: profileConnections, + storage: storage, + plexHome: plexHome, + ); + final accountConnection = registration.connection; - if (servers.isEmpty) { - await storage.clearCredentials(); + if (accountConnection.servers.isEmpty) { + // Nothing to roll back — the registered account row is exactly what + // a retry will upsert over; wiping global credentials here would + // also nuke the device identity and every server-endpoint cache. if (!mounted) return; setState(() { _isAuthenticating = false; - _errorMessage = t.serverSelection.noServersFoundForAccount(username: username, email: email); + _errorMessage = t.serverSelection.noServersFoundForAccount( + username: registration.username, + email: registration.email, + ); + }); + return; + } + + if (!registration.homeUsersFetched && plexHome.current[accountConnection.id] == null) { + // A failed home-user fetch is NOT "multiple users, let them pick": + // with no home users and no locals there is no profile to select, + // and navigating lands in a dead session. Surface a retry instead. + if (!mounted) return; + setState(() { + _isAuthenticating = false; + _errorMessage = t.profiles.failedToLoadHomeUsers; }); return; } - final clientId = await storage.getOrCreateClientIdentifier(); - final accountConnection = PlexAccountConnection( - // Key the row by the plex.tv account UUID so signing into a second - // Plex account on the same device produces a distinct row. The - // clientIdentifier is per-device and would collide. Falls back to - // clientId only if plex.tv didn't return a uuid (rare). - id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}', - accountToken: plexToken, - clientIdentifier: clientId, - accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'), - servers: servers, - createdAt: DateTime.now(), - lastAuthenticatedAt: DateTime.now(), - ); - await connectionRegistry.upsert(accountConnection); - await plexHome.refresh(accountConnection); if (!mounted) return; final activeProfiles = context.read(); await _selectInitialProfile(plexHome, activeProfiles, accountConnection); @@ -182,8 +189,6 @@ class _AuthScreenState extends State { _isAuthenticating = false; _errorMessage = t.serverSelection.failedToLoadServers(error: e); }); - } finally { - svc.dispose(); } } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 6eaeeaaf..e067bc94 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -30,6 +30,7 @@ import '../mixins/tab_visibility_aware.dart'; import '../navigation/navigation_tabs.dart'; import '../navigation/profile_navigation_scope.dart'; import '../profiles/active_profile_binder.dart'; +import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../providers/download_provider.dart'; @@ -60,6 +61,7 @@ import 'search_screen.dart'; import 'downloads/downloads_screen.dart'; import 'settings/settings_screen.dart'; import 'profile/profile_switch_screen.dart'; +import 'profile/profile_teardown.dart'; import '../services/system_shelf_service.dart'; import '../watch_together/watch_together.dart'; @@ -242,6 +244,7 @@ class _MainScreenState extends State // we only invalidate on id change and the libraries sidebar keeps // stale entries until the user switches profiles. bool _wasBindingPrev = false; + bool _hadProfiles = false; /// Subscription to MultiServerManager status changes. Used to resume any /// queued downloads as soon as a Plex client comes online for the first @@ -510,6 +513,16 @@ class _MainScreenState extends State _lastSeenProfileId = id; _wasBindingPrev = isBindingNow; + // Re-arm the initial-profile prompt when profiles arrive late (e.g. a + // slow home-user fetch landing after the empty first snapshot) while + // nothing is active — otherwise the one-shot post-frame prompt has + // already passed and the user is stuck in a session with no picker. + final hasProfilesNow = activeProfile.profiles.isNotEmpty; + if (!_hadProfiles && hasProfilesNow && id == null && !_isShowingProfileSelection) { + unawaited(_promptForInitialProfileSelection()); + } + _hadProfiles = hasProfilesNow; + // Same active id, but a rebind cycle for that profile just settled // (true → false transition). Fires after borrow / connection-removal // flows trigger ActiveProfileBinder.rebindIfActive, so the libraries @@ -524,6 +537,7 @@ class _MainScreenState extends State if (widget.initialPromptHandled) return; final activeProfile = context.read(); + final connections = context.read(); // The provider's initialize() is fire-and-forget from MultiProvider — // wait for it to settle so `active` and `profiles` reflect storage // before we decide whether to prompt. @@ -533,6 +547,20 @@ class _MainScreenState extends State final settingsService = await SettingsService.getInstance(); if (!mounted) return; + // Connections but ZERO resolvable profiles (e.g. the home-user fetch + // failed at sign-in): a session with nothing to select and no picker is + // a dead end. Mirror the boot guard — prune orphans and route to auth + // when nothing selectable remains. + if (activeProfile.active == null && activeProfile.profiles.isEmpty) { + // Offline, "unresolvable" may just be an unreachable plex.tv — don't + // kick the user to auth over it. + if (!widget.isOfflineMode && (await connections.list()).isNotEmpty && mounted) { + appLogger.w('MainScreen: connections exist but no profiles resolved — settling session'); + await settleSessionAfterRemoval(SessionTeardownScope.of(context)); + } + return; + } + // Always prompt when there's no active profile but profiles exist // (fresh sign-in with multiple Plex Home users): otherwise the binder // has no profile to bind, and the user lands on an empty screen with diff --git a/lib/screens/profile/add_local_profile_screen.dart b/lib/screens/profile/add_local_profile_screen.dart index 825342f6..232b7e0f 100644 --- a/lib/screens/profile/add_local_profile_screen.dart +++ b/lib/screens/profile/add_local_profile_screen.dart @@ -59,28 +59,35 @@ class _AddLocalProfileScreenState extends State with Cont void _clearPin() => setState(() => _pinHash = null); Future _saveAndContinue() async { + if (_saving) return; final name = _nameController.text.trim(); if (name.isEmpty) return; setState(() => _saving = true); - final registry = context.read(); - final profile = Profile.local( - id: 'local-${const Uuid().v4()}', - displayName: name, - pinHash: _pinHash, - sortOrder: DateTime.now().millisecondsSinceEpoch, - createdAt: DateTime.now(), - ); - await registry.upsert(profile); + try { + final registry = context.read(); + final profile = Profile.local( + id: 'local-${const Uuid().v4()}', + displayName: name, + pinHash: _pinHash, + sortOrder: DateTime.now().millisecondsSinceEpoch, + createdAt: DateTime.now(), + ); + await registry.upsert(profile); - if (!mounted) return; - // Drop the user into the connection picker so they end up with at least - // one connection. The picker offers both new sign-ins and borrowing from - // existing profiles — empty borrow lists no longer trap the user. - final navigator = Navigator.of(context); - await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile))); - if (!mounted) return; - navigator.pop(true); + if (!mounted) return; + // Drop the user into the connection picker so they end up with at least + // one connection. The picker offers both new sign-ins and borrowing from + // existing profiles — empty borrow lists no longer trap the user. + final navigator = Navigator.of(context); + await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile))); + if (!mounted) return; + navigator.pop(true); + } finally { + // A failed upsert (or returning from the connection picker without + // popping) must re-enable the button instead of wedging it. + if (mounted) setState(() => _saving = false); + } } @override diff --git a/lib/screens/profile/borrow_connection_screen.dart b/lib/screens/profile/borrow_connection_screen.dart index 3776b447..4748d73c 100644 --- a/lib/screens/profile/borrow_connection_screen.dart +++ b/lib/screens/profile/borrow_connection_screen.dart @@ -17,7 +17,6 @@ import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_merge.dart'; import '../../profiles/profile_registry.dart'; -import '../../services/plex_auth_service.dart'; import '../../services/storage_service.dart'; import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; @@ -214,6 +213,13 @@ class _BorrowConnectionScreenState extends State { case JellyfinConnection(): await _borrowJellyfin(cand); } + } catch (e, st) { + // Without this, a throw from the verify/borrow steps (network, DB) + // dies in the unawaited caller and the user gets no feedback. + appLogger.w('Borrow failed', error: e, stackTrace: st); + if (mounted) { + showErrorSnackBar(context, t.profiles.borrowFailed); + } } finally { if (mounted) { setState(() { @@ -257,77 +263,53 @@ class _BorrowConnectionScreenState extends State { if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount); return false; } - final auth = await PlexAuthService.create(); - try { - final result = await switchPlexHomeUserWithPin( - auth: auth, - accountToken: parent.accountToken, - homeUserUuid: homeUuid, - requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, - logLabel: cand.source.displayName, - ); - if (!result.succeeded) { - if (result.status == PlexHomeSwitchStatus.failed && mounted) { - showErrorSnackBar(context, t.profiles.failedToVerifyPin); - } - return false; + final result = await mintPlexHomeUserToken( + account: parent, + homeUserUuid: homeUuid, + requiresPin: true, + promptForPin: ({String? errorMessage}) async { + if (!mounted) return null; + return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); + }, + logLabel: cand.source.displayName, + ); + if (!result.succeeded) { + if (result.status == PlexHomeSwitchStatus.failed && mounted) { + showErrorSnackBar(context, t.profiles.failedToVerifyPin); } - return true; - } finally { - auth.dispose(); + return false; } + return true; } Future _borrowPlex(_BorrowCandidate cand) async { final pcRegistry = context.read(); - final auth = await PlexAuthService.create(); - try { - final account = cand.connection as PlexAccountConnection; - final result = await switchPlexHomeUserWithPin( - auth: auth, - accountToken: account.accountToken, - homeUserUuid: cand.pc.userIdentifier, - requiresPin: cand.source.plexProtected, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, - logLabel: cand.source.displayName, - ); - if (!result.succeeded) { - if (result.status == PlexHomeSwitchStatus.failed && mounted) { - showErrorSnackBar(context, t.profiles.borrowFailed); - } - return; - } - await pcRegistry.upsert( - ProfileConnection( - profileId: widget.targetProfile.id, - connectionId: account.id, - userToken: result.userToken!, - userIdentifier: cand.pc.userIdentifier, - tokenAcquiredAt: DateTime.now(), - ), - ); - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); - } - } catch (e, st) { - appLogger.w('Borrow failed', error: e, stackTrace: st); - if (mounted) { + final account = cand.connection as PlexAccountConnection; + final result = await mintPlexHomeUserToken( + account: account, + homeUserUuid: cand.pc.userIdentifier, + requiresPin: cand.source.plexProtected, + promptForPin: ({String? errorMessage}) async { + if (!mounted) return null; + return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); + }, + persistTo: pcRegistry, + persistProfileId: widget.targetProfile.id, + logLabel: cand.source.displayName, + ); + if (!result.succeeded) { + if (result.status == PlexHomeSwitchStatus.failed && mounted) { showErrorSnackBar(context, t.profiles.borrowFailed); } - } finally { - auth.dispose(); + return; + } + if (mounted) { + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; + } + showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); } } diff --git a/lib/screens/profile/pin_status_row.dart b/lib/screens/profile/pin_status_row.dart index 4a47212c..a5174baa 100644 --- a/lib/screens/profile/pin_status_row.dart +++ b/lib/screens/profile/pin_status_row.dart @@ -11,7 +11,11 @@ class PinStatusRow extends StatelessWidget { final VoidCallback onChange; final VoidCallback onRemove; - const PinStatusRow({super.key, required this.onChange, required this.onRemove}); + /// Owned by the parent so it can restore DPAD focus when this row swaps + /// in for the Set PIN button (whose node leaves the tree with it). + final FocusNode? changeFocusNode; + + const PinStatusRow({super.key, required this.onChange, required this.onRemove, this.changeFocusNode}); @override Widget build(BuildContext context) { @@ -35,6 +39,7 @@ class PinStatusRow extends StatelessWidget { ), const SizedBox(width: 12), FocusableButton( + focusNode: changeFocusNode, onPressed: onChange, child: TextButton(onPressed: onChange, child: Text(t.profiles.changePin)), ), diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 2f82937e..2a97d1fa 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -57,21 +57,45 @@ class _ProfileDetailScreenState extends State with Controll final _nameFocusNode = FocusNode(debugLabel: 'ProfileDetail:Name'); final _saveNameFocusNode = FocusNode(debugLabel: 'ProfileDetail:SaveName'); final _setPinFocusNode = FocusNode(debugLabel: 'ProfileDetail:SetPin'); + final _changePinFocusNode = FocusNode(debugLabel: 'ProfileDetail:ChangePin'); final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection'); final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile'); late Profile _profile; + StreamSubscription>? _profileSub; @override void initState() { super.initState(); _profile = widget.profile; + // Keep the snapshot live: the registry row can change underneath this + // screen (rename from another surface, PIN cleared elsewhere) and the + // header/PIN section would otherwise show stale state until reopened. + if (_profile.isLocal) { + _profileSub = context.read().watchProfiles().listen((locals) { + Profile? updated; + for (final p in locals) { + if (p.id == _profile.id) { + updated = p; + break; + } + } + if (updated == null || updated == _profile || !mounted) return; + final namePristine = _nameController.text.trim() == _profile.displayName; + setState(() { + _profile = updated!; + if (namePristine) _nameController.text = updated.displayName; + }); + }); + } } @override void dispose() { + _profileSub?.cancel(); _nameFocusNode.dispose(); _saveNameFocusNode.dispose(); _setPinFocusNode.dispose(); + _changePinFocusNode.dispose(); _addConnectionFocusNode.dispose(); _deleteProfileFocusNode.dispose(); super.dispose(); @@ -95,10 +119,18 @@ class _ProfileDetailScreenState extends State with Controll if (pin == null || !mounted) return; final profile = _profile; if (profile is! LocalProfile) return; + final hadPin = profile.pinHash != null; final updated = profile.copyWith(pinHash: computePinHash(pin)); await context.read().upsert(updated); if (!mounted) return; setState(() => _profile = updated); + if (!hadPin) { + // The Set PIN button (and its focus node) just left the tree — hand + // DPAD focus to the replacing row instead of dropping it. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _changePinFocusNode.requestFocus(); + }); + } } Future _clearPin() async { @@ -108,6 +140,11 @@ class _ProfileDetailScreenState extends State with Controll await context.read().upsert(updated); if (!mounted) return; setState(() => _profile = updated); + // Reverse swap of _setPin: the row (and the focused Remove button) + // just left the tree. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _setPinFocusNode.requestFocus(); + }); } Future _addConnection() async { @@ -126,23 +163,60 @@ class _ProfileDetailScreenState extends State with Controll isDestructive: true, ); if (!confirmed || !mounted) return; - await context.read().releaseDownloadsForProfileServers( + final downloads = context.read(); + final pcRegistry = context.read(); + final connRegistry = context.read(); + final storage = context.read(); + final serverManager = context.read().serverManager; + final hiddenLibraries = context.read(); + final binder = context.read(); + + // Release downloads only for servers the profile actually loses — the + // same server can stay reachable through another connection (a second + // Plex account sharing the server, another Jellyfin user). + final retainedServerIds = await _retainedServerIds( + excludingConnectionId: conn.id, + profileConnections: pcRegistry, + connections: connRegistry, + ); + await downloads.releaseDownloadsForProfileServers( _profile.id, - _serverIdsForConnection(conn), + _serverIdsForConnection(conn).difference(retainedServerIds), ); - if (!mounted) return; await removeProfileConnectionAndCleanup( profileId: _profile.id, connection: conn, - profileConnections: context.read(), - connections: context.read(), - storage: context.read(), - serverManager: context.read().serverManager, + profileConnections: pcRegistry, + connections: connRegistry, + storage: storage, + serverManager: serverManager, ); - if (!mounted) return; - await context.read()?.refresh(); - if (!mounted) return; - unawaited(context.read().rebindIfActive(_profile.id)); + await hiddenLibraries?.refresh(); + unawaited(binder.rebindIfActive(_profile.id)); + } + + /// Server ids the profile keeps after removing [excludingConnectionId]: + /// its other join rows plus, for Plex Home profiles, the implicit parent + /// account. + Future> _retainedServerIds({ + required String excludingConnectionId, + required ProfileConnectionRegistry profileConnections, + required ConnectionRegistry connections, + }) async { + final rows = await profileConnections.listForProfile(_profile.id); + final byId = {for (final c in await connections.list()) c.id: c}; + final retained = {}; + for (final row in rows) { + if (row.connectionId == excludingConnectionId) continue; + final other = byId[row.connectionId]; + if (other != null) retained.addAll(_serverIdsForConnection(other)); + } + final parentId = _profile.isPlexHome ? _profile.parentConnectionId : null; + if (parentId != null && parentId != excludingConnectionId) { + final parent = byId[parentId]; + if (parent != null) retained.addAll(_serverIdsForConnection(parent)); + } + return retained; } Future _editConnection(Connection conn) async { @@ -245,7 +319,7 @@ class _ProfileDetailScreenState extends State with Controll ), ) else - PinStatusRow(onChange: _setPin, onRemove: _clearPin), + PinStatusRow(onChange: _setPin, onRemove: _clearPin, changeFocusNode: _changePinFocusNode), const SizedBox(height: 32), Row( children: [ @@ -287,7 +361,7 @@ class _ProfileDetailScreenState extends State with Controll } } -class _ConnectionsList extends StatelessWidget { +class _ConnectionsList extends StatefulWidget { final Profile profile; final Future Function(ProfileConnection pc, Connection conn) onRemove; final Future Function(Connection conn) onEdit; @@ -300,15 +374,37 @@ class _ConnectionsList extends StatelessWidget { required this.onSignOutParent, }); + @override + State<_ConnectionsList> createState() => _ConnectionsListState(); +} + +class _ConnectionsListState extends State<_ConnectionsList> { + // Created once: building streams/futures inside build re-subscribes and + // refetches on every parent rebuild (each keystroke in the name field), + // flashing the spinner and hammering the DB. + Stream>? _pcsStream; + Stream>>? _homeStream; + Map>? _homeInitial; + Stream>? _connectionsStream; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _pcsStream ??= context.read().watchForProfile(widget.profile.id); + final plexHome = context.read(); + _homeStream ??= plexHome.stream; + _homeInitial ??= plexHome.current; + _connectionsStream ??= context.read().watchConnections(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final profile = widget.profile; final pcRegistry = context.read(); - final connRegistry = context.read(); - final plexHome = context.read(); return StreamBuilder>( - stream: pcRegistry.watchForProfile(profile.id), + stream: _pcsStream, builder: (context, snapshot) { final pcs = snapshot.data ?? const []; if (snapshot.connectionState == ConnectionState.waiting) { @@ -318,12 +414,12 @@ class _ConnectionsList extends StatelessWidget { ); } return StreamBuilder>>( - stream: plexHome.stream, - initialData: plexHome.current, + stream: _homeStream, + initialData: _homeInitial, builder: (context, homeSnap) { final homeCache = homeSnap.data ?? const >{}; - return FutureBuilder>( - future: connRegistry.list(), + return StreamBuilder>( + stream: _connectionsStream, builder: (context, snap) { final all = snap.data ?? const []; final byId = {for (final c in all) c.id: c}; @@ -356,7 +452,7 @@ class _ConnectionsList extends StatelessWidget { tooltip: t.profiles.manage, onSelected: (value) { if (value == 'sign_out') { - unawaited(onSignOutParent(parentConn)); + unawaited(widget.onSignOutParent(parentConn)); } }, itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)], @@ -377,9 +473,9 @@ class _ConnectionsList extends StatelessWidget { if (value == 'default') { unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); } else if (value == 'edit') { - unawaited(onEdit(conn)); + unawaited(widget.onEdit(conn)); } else if (value == 'remove') { - unawaited(onRemove(pc, conn)); + unawaited(widget.onRemove(pc, conn)); } }, itemBuilder: (_) => [ diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index dae29f91..cd9bea95 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -106,8 +106,11 @@ class _ProfileSwitchScreenState extends State with MountedS }, child: StreamBuilder( stream: _viewStream, - initialData: ProfilesView.empty, builder: (context, snapshot) { + // No initialData: rendering ProfilesView.empty while the first + // combine is in flight flashes the "No profiles available" error + // state on every open. + final loading = snapshot.data == null; final view = snapshot.data ?? ProfilesView.empty; _pruneProfileFocusResources(view.profiles.map((p) => p.id).toSet()); // `context.select` only rebuilds when `activeId` actually @@ -124,11 +127,13 @@ class _ProfileSwitchScreenState extends State with MountedS slivers: [ if (view.profiles.isEmpty) SliverFillRemaining( - child: EmptyStateWidget( - message: t.messages.noProfilesAvailable, - subtitle: t.messages.contactAdminForProfiles, - icon: Symbols.person_off_rounded, - ), + child: loading + ? const Center(child: CircularProgressIndicator()) + : EmptyStateWidget( + message: t.messages.noProfilesAvailable, + subtitle: t.messages.contactAdminForProfiles, + icon: Symbols.person_off_rounded, + ), ) else ..._buildSections(view, activeId), @@ -172,14 +177,23 @@ class _ProfileSwitchScreenState extends State with MountedS } void _pruneProfileFocusResources(Set activeIds) { + // Runs during build (from the StreamBuilder). Detach the map entries + // synchronously so tiles never receive a stale node, but defer the + // actual dispose to after the frame: on TV the pruned tile's node is + // often the one holding primary focus (the profile just signed out / + // deleted), and disposing the focused node mid-build wedges the focus + // system on DPAD-only devices. + final removed = []; for (final id in _profileFocusNodes.keys.toList()) { if (!activeIds.contains(id)) { - _profileFocusNodes.remove(id)?.dispose(); + final node = _profileFocusNodes.remove(id); + if (node != null) removed.add(node); } } for (final id in _profileMenuFocusNodes.keys.toList()) { if (!activeIds.contains(id)) { - _profileMenuFocusNodes.remove(id)?.dispose(); + final node = _profileMenuFocusNodes.remove(id); + if (node != null) removed.add(node); } } for (final id in _profileMenuKeys.keys.toList()) { @@ -187,6 +201,13 @@ class _ProfileSwitchScreenState extends State with MountedS _profileMenuKeys.remove(id); } } + if (removed.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final node in removed) { + node.dispose(); + } + }); + } } void _openProfileMenu(Profile profile) { @@ -206,9 +227,14 @@ class _ProfileSwitchScreenState extends State with MountedS final profileFocusNode = _profileFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile); final menuKey = _profileMenuKey(profile); - final onManage = !widget.requireSelection ? () => _manageProfile(profile) : null; - final onDelete = profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null; - final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection + // All tile actions are disabled while a switch is binding: the + // overlay's barrier blocks pointers but not DPAD key events, and a + // Manage/Delete flow racing the in-flight switch corrupts state + // (e.g. a delete confirmation left open when the switch settles). + final actionsEnabled = !widget.requireSelection && !_switching; + final onManage = actionsEnabled ? () => _manageProfile(profile) : null; + final onDelete = profile.isLocal && actionsEnabled ? () => _deleteProfile(profile) : null; + final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && actionsEnabled ? () => _signOutPlexAccount(profile) : null; final hasMenu = onManage != null || onDelete != null || onSignOut != null; @@ -307,13 +333,20 @@ class _ProfileSwitchScreenState extends State with MountedS if (_switching) return; setState(() => _switching = true); try { + final route = ModalRoute.of(context); final navigator = Navigator.of(context, rootNavigator: true); final switched = await switchProfileFromUi(context, profile); if (!mounted || !switched) return; if (widget.requireSelection) { setState(() => _allowPop = true); } - navigator.pop(true); + // Pop only when this screen is still the top route. A blind + // `navigator.pop(true)` after the unbounded switch-await pops + // whatever is topmost — it can dismiss a confirmation dialog WITH + // `true` (auto-confirming a delete) or close the wrong screen. + if (route != null && route.isCurrent) { + navigator.pop(true); + } } finally { setStateIfMounted(() => _switching = false); } diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 73a303cd..122e7f5a 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -7,14 +7,16 @@ import 'package:provider/provider.dart'; import '../../connection/connection.dart'; import '../../connection/connection_registry.dart'; +import '../../connection/plex_account_setup.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; +import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection_registry.dart'; -import '../../services/plex_auth_service.dart'; +import '../../services/storage_service.dart'; import '../../utils/app_logger.dart'; import '../../media/media_backend.dart'; import '../../widgets/backend_badge.dart'; @@ -22,7 +24,6 @@ import '../../widgets/focused_scroll_scaffold.dart'; import '../auth/plex_pin_auth_flow.dart'; import '../profile/borrow_connection_screen.dart'; import 'async_form_state_mixin.dart'; -import 'connection_persistence.dart'; /// Add a Plex account to the [ConnectionRegistry]. /// @@ -52,83 +53,56 @@ class _AddPlexAccountScreenState extends State with AsyncF Future _onTokenReceived(String token) async { final completed = await runAsync( () async { - // Pull the account label first so the row is human-readable. Falls - // back to "Plex" when the user info call fails (rare; e.g. token - // works but plex.tv is rate-limiting). - String accountLabel = 'Plex'; - // Account UUID from plex.tv — this is what makes multi-account work. - // The clientIdentifier is per-device (same for every Plex account on - // this install), so keying connection.id off it would collapse two - // different Plex accounts into the same row. Falls back to the - // client identifier only if the user-info call fails outright; - // re-signing into the same account will then upsert the legacy row. - String accountUuid = ''; - final auth = await PlexAuthService.create(); - try { - try { - final info = await auth.getUserInfo(token); - accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex'; - final uuid = (info['uuid'] as String?)?.trim(); - if (uuid != null && uuid.isNotEmpty) accountUuid = uuid; - } catch (e) { - appLogger.d('getUserInfo after add-account failed (using fallback): $e'); - } + final connRegistry = context.read(); + final pcRegistry = context.read(); + final plexHome = context.read(); + final storage = context.read(); + final target = widget.targetProfile; - final servers = await auth.fetchServers(token); - if (!mounted) return false; + // Shared token→connection pipeline: identity resolution, dedup of a + // legacy client-id-keyed row, registry upsert, and the home-user + // fetch (which must land before the borrow screen — it reads + // `activeProvider.profiles` once in initState). Binding is + // deliberately left to ActiveProfileBinder below (global reauth) or + // the borrow flow (profile-scoped add) so we never put the raw + // account token into the active runtime session. + final registration = await registerPlexAccountFromToken( + token: token, + connections: connRegistry, + profileConnections: pcRegistry, + storage: storage, + plexHome: plexHome, + ); + final connection = registration.connection; - final connection = PlexAccountConnection( - id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}', - accountToken: token, - clientIdentifier: auth.clientIdentifier, - accountLabel: accountLabel, - servers: servers, - createdAt: DateTime.now(), - lastAuthenticatedAt: DateTime.now(), + if (!mounted) return false; + if (target != null) { + final borrowed = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)), ); - - if (!mounted) return false; - // Persist the registry row. Binding is deliberately left to - // ActiveProfileBinder below (global reauth) or the borrow flow - // (profile-scoped add) so we never put the raw account token into - // the active runtime session. - final target = widget.targetProfile; - await persistAndBindConnection( - context: context, - connection: connection, - bindToProfile: null, - addToManager: null, - ); - - if (!mounted) return false; - // Live-fetch the new account's Home users into [PlexHomeService]'s - // cache so the picker immediately surfaces them as virtual profiles. - // Must be awaited before pushing the borrow screen — that screen - // reads `activeProvider.profiles` once in initState (no reactive - // subscription), so navigating before the home users land yields - // an empty candidate list. Errors are swallowed inside - // `_fetchAndCache`; await is safe. - await context.read().refresh(connection); - - if (!mounted) return false; - if (target != null) { - final borrowed = await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)), - ); - if (!mounted) return borrowed == true; - if (borrowed == true) { - Navigator.of(context).pop(true); - return true; + if (borrowed != true) { + // The user backed out of picking a home user — a cancel, not an + // error. If this flow created the account solely to attach it + // to the profile, remove it again so a cancelled attach doesn't + // leave a global account behind. + if (!registration.existedBefore) { + await removePlexAccountConnectionAndCleanup( + account: connection, + profileConnections: pcRegistry, + connections: connRegistry, + storage: storage, + ); } - throw StateError(t.addServer.failedToRegisterAccount(error: 'Connection was not borrowed')); + if (mounted) Navigator.of(context).pop(false); + return true; } - await _rebindActiveIfUses(connection.id); - if (!mounted) return false; - Navigator.of(context).pop(true); + if (mounted) Navigator.of(context).pop(true); return true; - } finally { - auth.dispose(); } + await _rebindActiveIfUses(connection.id); + if (!mounted) return false; + Navigator.of(context).pop(true); + return true; }, errorMapper: (e) { appLogger.e('Failed to register Plex account', error: e); diff --git a/lib/screens/settings/connection_persistence.dart b/lib/screens/settings/connection_persistence.dart index c2cce4c6..d55bc24a 100644 --- a/lib/screens/settings/connection_persistence.dart +++ b/lib/screens/settings/connection_persistence.dart @@ -34,11 +34,16 @@ Future persistAndBindConnection({ required Future Function()? addToManager, String? visibleServerId, }) async { - await context.read().upsert(connection); + // Snapshot the collaborators up front: persistence must complete even if + // the screen unmounts mid-await — a connection upserted without its join + // row is an orphan the profile never sees. Only the session-facing steps + // below stay gated on `mounted`. + final connections = context.read(); + final profileConnections = context.read(); - if (!context.mounted) return false; + await connections.upsert(connection); if (bindToProfile != null) { - await context.read().upsert(bindToProfile); + await profileConnections.upsert(bindToProfile); } if (!context.mounted || addToManager == null) return false; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index f3825d0c..e36158f2 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -40,7 +40,6 @@ import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; -import '../../profiles/profile_registry.dart'; import 'about_screen.dart'; import 'add_connection_screen.dart'; import 'appearance_settings_screen.dart'; @@ -276,29 +275,26 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildProfilesSection() { - return StreamBuilder>( - stream: context.read().watchProfiles(), - builder: (context, snapshot) { - final count = snapshot.data?.length ?? 0; - // `context.select` so this StreamBuilder doesn't rebuild on every - // ActiveProfileProvider notification — only when the active - // profile's display name actually changes. - final activeName = context.select((p) => p.active?.displayName); - final subtitle = count <= 1 - ? t.profiles.summarySingle - : (activeName != null - ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) - : t.profiles.summaryMultiple(count: count)); - return SettingNavigationTile( - icon: Symbols.group_rounded, - title: t.profiles.sectionTitle, - subtitle: subtitle, - onTap: () => Navigator.of( - context, - rootNavigator: true, - ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())), - ); - }, + // ActiveProfileProvider already merges local rows with virtual Plex + // Home profiles — counting only the local DB rows made every Plex Home + // household read as a single profile here. `context.select` keeps + // rebuilds scoped to actual count/name changes (a StreamBuilder here + // was also re-created on every settings rebuild). + final count = context.select((p) => p.profiles.length); + final activeName = context.select((p) => p.active?.displayName); + final subtitle = count <= 1 + ? t.profiles.summarySingle + : (activeName != null + ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) + : t.profiles.summaryMultiple(count: count)); + return SettingNavigationTile( + icon: Symbols.group_rounded, + title: t.profiles.sectionTitle, + subtitle: subtitle, + onTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())), ); } diff --git a/test/screens/profile/profile_detail_screen_test.dart b/test/screens/profile/profile_detail_screen_test.dart index e7fb0f33..20f31524 100644 --- a/test/screens/profile/profile_detail_screen_test.dart +++ b/test/screens/profile/profile_detail_screen_test.dart @@ -36,7 +36,7 @@ void main() { TvDetectionService.debugSetAppleTVOverride(true); final db = AppDatabase.forTesting(NativeDatabase.memory()); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); - final profiles = ProfileRegistry(db); + final profiles = _FakeProfileRegistry(db, [profile]); final connections = _FakeConnectionRegistry(db); final profileConnections = _FakeProfileConnectionRegistry(db); final storage = await StorageService.getInstance(); @@ -106,6 +106,20 @@ class _FakeConnectionRegistry extends ConnectionRegistry { @override Future> list() async => const []; + + // Synthetic stream: a real drift watch leaves the stream store's + // keep-alive timer pending when the test ends. + @override + Stream> watchConnections() => Stream.value(const []); +} + +class _FakeProfileRegistry extends ProfileRegistry { + _FakeProfileRegistry(super.db, this._profiles); + + final List _profiles; + + @override + Stream> watchProfiles() => Stream.value(_profiles); } class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {