refactor(profiles): shared auth/mint flows + screen fixes
This commit is contained in:
@@ -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<PlexAccountRegistration> 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<void> _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}');
|
||||
}
|
||||
@@ -636,7 +636,9 @@
|
||||
"borrowConnectionBorrowed": "Връзката е използвана.",
|
||||
"borrowFailed": "Неуспешно използване на връзка.",
|
||||
"incorrectPin": "Неправилен PIN.",
|
||||
"incorrectPinTryAgain": "Неправилен PIN. Опитайте отново.",
|
||||
"sourceProfileMissingParentAccount": "Изходният профил няма родителски акаунт.",
|
||||
"failedToLoadHomeUsers": "Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.",
|
||||
"failedToVerifyPin": "Неуспешна проверка на PIN.",
|
||||
"newProfile": "Нов профил",
|
||||
"profileNameHint": "напр. Гости, Деца, Семейна стая",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -636,7 +636,9 @@
|
||||
"borrowConnectionBorrowed": "接続を借用しました。",
|
||||
"borrowFailed": "接続を借用できませんでした。",
|
||||
"incorrectPin": "PINが正しくありません。",
|
||||
"incorrectPinTryAgain": "PINが正しくありません。もう一度お試しください。",
|
||||
"sourceProfileMissingParentAccount": "ソースプロフィールに親アカウントがありません。",
|
||||
"failedToLoadHomeUsers": "Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。",
|
||||
"failedToVerifyPin": "PINを確認できませんでした。",
|
||||
"newProfile": "新しいプロファイル",
|
||||
"profileNameHint": "例:ゲスト、キッズ、ファミリールーム",
|
||||
|
||||
@@ -636,7 +636,9 @@
|
||||
"borrowConnectionBorrowed": "연결을 빌렸습니다.",
|
||||
"borrowFailed": "연결을 빌리지 못했습니다.",
|
||||
"incorrectPin": "PIN이 올바르지 않습니다.",
|
||||
"incorrectPinTryAgain": "PIN이 올바르지 않습니다. 다시 시도하세요.",
|
||||
"sourceProfileMissingParentAccount": "원본 프로필에 상위 계정이 없습니다.",
|
||||
"failedToLoadHomeUsers": "Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
"failedToVerifyPin": "PIN을 확인하지 못했습니다.",
|
||||
"newProfile": "새 프로필",
|
||||
"profileNameHint": "예: 손님, 어린이, 가족실",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -636,7 +636,9 @@
|
||||
"borrowConnectionBorrowed": "Подключение заимствовано.",
|
||||
"borrowFailed": "Не удалось заимствовать подключение.",
|
||||
"incorrectPin": "Неверный PIN.",
|
||||
"incorrectPinTryAgain": "Неверный PIN. Попробуйте ещё раз.",
|
||||
"sourceProfileMissingParentAccount": "У исходного профиля отсутствует родительская учетная запись.",
|
||||
"failedToLoadHomeUsers": "Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.",
|
||||
"failedToVerifyPin": "Не удалось проверить PIN.",
|
||||
"newProfile": "Новый профиль",
|
||||
"profileNameHint": "например, Гости, Дети, Семейная комната",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => 'Повторно свързване...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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' => '再接続中...',
|
||||
|
||||
@@ -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' => '재연결 중...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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' => 'Переподключение...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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' => '重新连接中...',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -636,7 +636,9 @@
|
||||
"borrowConnectionBorrowed": "已借用连接。",
|
||||
"borrowFailed": "无法借用连接。",
|
||||
"incorrectPin": "PIN 不正确。",
|
||||
"incorrectPinTryAgain": "PIN 不正确。请重试。",
|
||||
"sourceProfileMissingParentAccount": "源个人资料缺少其父账号。",
|
||||
"failedToLoadHomeUsers": "无法加载您的 Plex Home 用户。请检查网络连接后重试。",
|
||||
"failedToVerifyPin": "无法验证 PIN。",
|
||||
"newProfile": "新建配置文件",
|
||||
"profileNameHint": "例如:访客、儿童、家庭房",
|
||||
|
||||
@@ -168,12 +168,15 @@ class PlexHomeService {
|
||||
}
|
||||
|
||||
/// Force-refresh a single account. Useful after sign-in / borrow flows.
|
||||
Future<void> 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<bool> refresh(PlexAccountConnection conn) => _fetchAndCache(conn);
|
||||
|
||||
Future<void> _fetchAndCache(PlexAccountConnection conn) async {
|
||||
Future<bool> _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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PlexHomeSwitchResult> 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<PlexHomeSwitchResult> 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<PlexHomeSwitchResult> 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;
|
||||
|
||||
@@ -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<bool> activateProfileWithPin(BuildContext context, Profile profile) async {
|
||||
Future<ProfileActivationOutcome> activateProfileWithPin(BuildContext context, Profile profile) async {
|
||||
final active = context.read<ActiveProfileProvider>();
|
||||
final binder = context.read<ActiveProfileBinder>();
|
||||
|
||||
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<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
|
||||
final activeProvider = context.read<ActiveProfileProvider>();
|
||||
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<bool> 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<bool> _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<PlexHomeSwitchStatus> _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<ConnectionRegistry>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final binder = context.read<ActiveProfileBinder>();
|
||||
final all = await connections.list();
|
||||
PlexAccountConnection? account;
|
||||
for (final c in all) {
|
||||
@@ -105,39 +119,22 @@ Future<bool> _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<ProfileConnectionRegistry>();
|
||||
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<ActiveProfileBinder>().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.
|
||||
|
||||
@@ -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<AuthScreen> {
|
||||
@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<void> _initVerifyService() async {
|
||||
@@ -96,44 +100,47 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
});
|
||||
|
||||
final connectionRegistry = context.read<ConnectionRegistry>();
|
||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
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<ActiveProfileProvider>();
|
||||
await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
|
||||
@@ -182,8 +189,6 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
_isAuthenticating = false;
|
||||
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
|
||||
});
|
||||
} finally {
|
||||
svc.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MainScreen>
|
||||
// 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<MainScreen>
|
||||
_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<MainScreen>
|
||||
if (widget.initialPromptHandled) return;
|
||||
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
// 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<MainScreen>
|
||||
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
|
||||
|
||||
@@ -59,28 +59,35 @@ class _AddLocalProfileScreenState extends State<AddLocalProfileScreen> with Cont
|
||||
void _clearPin() => setState(() => _pinHash = null);
|
||||
|
||||
Future<void> _saveAndContinue() async {
|
||||
if (_saving) return;
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
|
||||
final registry = context.read<ProfileRegistry>();
|
||||
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<ProfileRegistry>();
|
||||
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
|
||||
|
||||
@@ -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<BorrowConnectionScreen> {
|
||||
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<BorrowConnectionScreen> {
|
||||
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<void> _borrowPlex(_BorrowCandidate cand) async {
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
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<ActiveProfileBinder>().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<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
||||
if (widget.popOnSuccess) {
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
|
||||
@@ -57,21 +57,45 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> 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<List<Profile>>? _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<ProfileRegistry>().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<ProfileDetailScreen> 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<ProfileRegistry>().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<void> _clearPin() async {
|
||||
@@ -108,6 +140,11 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
await context.read<ProfileRegistry>().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<void> _addConnection() async {
|
||||
@@ -126,23 +163,60 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
isDestructive: true,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
await context.read<DownloadProvider>().releaseDownloadsForProfileServers(
|
||||
final downloads = context.read<DownloadProvider>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final connRegistry = context.read<ConnectionRegistry>();
|
||||
final storage = context.read<StorageService>();
|
||||
final serverManager = context.read<MultiServerProvider>().serverManager;
|
||||
final hiddenLibraries = context.read<HiddenLibrariesProvider?>();
|
||||
final binder = context.read<ActiveProfileBinder>();
|
||||
|
||||
// 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<ProfileConnectionRegistry>(),
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
storage: context.read<StorageService>(),
|
||||
serverManager: context.read<MultiServerProvider>().serverManager,
|
||||
profileConnections: pcRegistry,
|
||||
connections: connRegistry,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await context.read<HiddenLibrariesProvider?>()?.refresh();
|
||||
if (!mounted) return;
|
||||
unawaited(context.read<ActiveProfileBinder>().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<Set<String>> _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 = <String>{};
|
||||
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<void> _editConnection(Connection conn) async {
|
||||
@@ -245,7 +319,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> 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<ProfileDetailScreen> with Controll
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectionsList extends StatelessWidget {
|
||||
class _ConnectionsList extends StatefulWidget {
|
||||
final Profile profile;
|
||||
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
|
||||
final Future<void> 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<List<ProfileConnection>>? _pcsStream;
|
||||
Stream<Map<String, List<PlexHomeUser>>>? _homeStream;
|
||||
Map<String, List<PlexHomeUser>>? _homeInitial;
|
||||
Stream<List<Connection>>? _connectionsStream;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_pcsStream ??= context.read<ProfileConnectionRegistry>().watchForProfile(widget.profile.id);
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
_homeStream ??= plexHome.stream;
|
||||
_homeInitial ??= plexHome.current;
|
||||
_connectionsStream ??= context.read<ConnectionRegistry>().watchConnections();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final profile = widget.profile;
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final connRegistry = context.read<ConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
|
||||
return StreamBuilder<List<ProfileConnection>>(
|
||||
stream: pcRegistry.watchForProfile(profile.id),
|
||||
stream: _pcsStream,
|
||||
builder: (context, snapshot) {
|
||||
final pcs = snapshot.data ?? const <ProfileConnection>[];
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
@@ -318,12 +414,12 @@ class _ConnectionsList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return StreamBuilder<Map<String, List<PlexHomeUser>>>(
|
||||
stream: plexHome.stream,
|
||||
initialData: plexHome.current,
|
||||
stream: _homeStream,
|
||||
initialData: _homeInitial,
|
||||
builder: (context, homeSnap) {
|
||||
final homeCache = homeSnap.data ?? const <String, List<PlexHomeUser>>{};
|
||||
return FutureBuilder<List<Connection>>(
|
||||
future: connRegistry.list(),
|
||||
return StreamBuilder<List<Connection>>(
|
||||
stream: _connectionsStream,
|
||||
builder: (context, snap) {
|
||||
final all = snap.data ?? const <Connection>[];
|
||||
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: (_) => [
|
||||
|
||||
@@ -106,8 +106,11 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
},
|
||||
child: StreamBuilder<ProfilesView>(
|
||||
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<ProfileSwitchScreen> 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<ProfileSwitchScreen> with MountedS
|
||||
}
|
||||
|
||||
void _pruneProfileFocusResources(Set<String> 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 = <FocusNode>[];
|
||||
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<ProfileSwitchScreen> 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<ProfileSwitchScreen> 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<ProfileSwitchScreen> 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);
|
||||
}
|
||||
|
||||
@@ -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<AddPlexAccountScreen> with AsyncF
|
||||
Future<void> _onTokenReceived(String token) async {
|
||||
final completed = await runAsync<bool>(
|
||||
() 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<ConnectionRegistry>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
final storage = context.read<StorageService>();
|
||||
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<bool>(
|
||||
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<PlexHomeService>().refresh(connection);
|
||||
|
||||
if (!mounted) return false;
|
||||
if (target != null) {
|
||||
final borrowed = await Navigator.of(context).push<bool>(
|
||||
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);
|
||||
|
||||
@@ -34,11 +34,16 @@ Future<bool> persistAndBindConnection({
|
||||
required Future<bool> Function()? addToManager,
|
||||
String? visibleServerId,
|
||||
}) async {
|
||||
await context.read<ConnectionRegistry>().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<ConnectionRegistry>();
|
||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||
|
||||
if (!context.mounted) return false;
|
||||
await connections.upsert(connection);
|
||||
if (bindToProfile != null) {
|
||||
await context.read<ProfileConnectionRegistry>().upsert(bindToProfile);
|
||||
await profileConnections.upsert(bindToProfile);
|
||||
}
|
||||
|
||||
if (!context.mounted || addToManager == null) return false;
|
||||
|
||||
@@ -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<SettingsScreen> with FocusableTab, Moun
|
||||
}
|
||||
|
||||
Widget _buildProfilesSection() {
|
||||
return StreamBuilder<List<Profile>>(
|
||||
stream: context.read<ProfileRegistry>().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<ActiveProfileProvider, String?>((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<ActiveProfileProvider, int>((p) => p.profiles.length);
|
||||
final activeName = context.select<ActiveProfileProvider, String?>((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())),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Connection>> list() async => const [];
|
||||
|
||||
// Synthetic stream: a real drift watch leaves the stream store's
|
||||
// keep-alive timer pending when the test ends.
|
||||
@override
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(const []);
|
||||
}
|
||||
|
||||
class _FakeProfileRegistry extends ProfileRegistry {
|
||||
_FakeProfileRegistry(super.db, this._profiles);
|
||||
|
||||
final List<Profile> _profiles;
|
||||
|
||||
@override
|
||||
Stream<List<Profile>> watchProfiles() => Stream.value(_profiles);
|
||||
}
|
||||
|
||||
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
|
||||
Reference in New Issue
Block a user