refactor(profiles): shared auth/mint flows + screen fixes

This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent f9123eb40f
commit 44be03d39c
48 changed files with 761 additions and 345 deletions
+119
View File
@@ -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}');
}
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Връзката е използвана.",
"borrowFailed": "Неуспешно използване на връзка.",
"incorrectPin": "Неправилен PIN.",
"incorrectPinTryAgain": "Неправилен PIN. Опитайте отново.",
"sourceProfileMissingParentAccount": "Изходният профил няма родителски акаунт.",
"failedToLoadHomeUsers": "Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.",
"failedToVerifyPin": "Неуспешна проверка на PIN.",
"newProfile": "Нов профил",
"profileNameHint": "напр. Гости, Деца, Семейна стая",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "接続を借用しました。",
"borrowFailed": "接続を借用できませんでした。",
"incorrectPin": "PINが正しくありません。",
"incorrectPinTryAgain": "PINが正しくありません。もう一度お試しください。",
"sourceProfileMissingParentAccount": "ソースプロフィールに親アカウントがありません。",
"failedToLoadHomeUsers": "Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。",
"failedToVerifyPin": "PINを確認できませんでした。",
"newProfile": "新しいプロファイル",
"profileNameHint": "例:ゲスト、キッズ、ファミリールーム",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "연결을 빌렸습니다.",
"borrowFailed": "연결을 빌리지 못했습니다.",
"incorrectPin": "PIN이 올바르지 않습니다.",
"incorrectPinTryAgain": "PIN이 올바르지 않습니다. 다시 시도하세요.",
"sourceProfileMissingParentAccount": "원본 프로필에 상위 계정이 없습니다.",
"failedToLoadHomeUsers": "Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"failedToVerifyPin": "PIN을 확인하지 못했습니다.",
"newProfile": "새 프로필",
"profileNameHint": "예: 손님, 어린이, 가족실",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Подключение заимствовано.",
"borrowFailed": "Не удалось заимствовать подключение.",
"incorrectPin": "Неверный PIN.",
"incorrectPinTryAgain": "Неверный PIN. Попробуйте ещё раз.",
"sourceProfileMissingParentAccount": "У исходного профиля отсутствует родительская учетная запись.",
"failedToLoadHomeUsers": "Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.",
"failedToVerifyPin": "Не удалось проверить PIN.",
"newProfile": "Новый профиль",
"profileNameHint": "например, Гости, Дети, Семейная комната",
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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' => 'Повторно свързване...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+10 -2
View File
@@ -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',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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' => '再接続中...',
+6 -2
View File
@@ -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' => '재연결 중...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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' => 'Переподключение...',
+6 -2
View File
@@ -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...',
+6 -2
View File
@@ -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' => '重新连接中...',
+2
View File
@@ -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",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "已借用连接。",
"borrowFailed": "无法借用连接。",
"incorrectPin": "PIN 不正确。",
"incorrectPinTryAgain": "PIN 不正确。请重试。",
"sourceProfileMissingParentAccount": "源个人资料缺少其父账号。",
"failedToLoadHomeUsers": "无法加载您的 Plex Home 用户。请检查网络连接后重试。",
"failedToVerifyPin": "无法验证 PIN。",
"newProfile": "新建配置文件",
"profileNameHint": "例如:访客、儿童、家庭房",
+10 -5
View File
@@ -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;
}
}
+51 -1
View File
@@ -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;
+57 -60
View File
@@ -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.
+34 -29
View File
@@ -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();
}
}
+28
View File
@@ -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);
}
}
+6 -1
View File
@@ -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)),
),
+119 -23
View File
@@ -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: (_) => [
+45 -12
View File
@@ -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;
+20 -24
View File
@@ -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 {