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": "Връзката е използвана.", "borrowConnectionBorrowed": "Връзката е използвана.",
"borrowFailed": "Неуспешно използване на връзка.", "borrowFailed": "Неуспешно използване на връзка.",
"incorrectPin": "Неправилен PIN.", "incorrectPin": "Неправилен PIN.",
"incorrectPinTryAgain": "Неправилен PIN. Опитайте отново.",
"sourceProfileMissingParentAccount": "Изходният профил няма родителски акаунт.", "sourceProfileMissingParentAccount": "Изходният профил няма родителски акаунт.",
"failedToLoadHomeUsers": "Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.",
"failedToVerifyPin": "Неуспешна проверка на PIN.", "failedToVerifyPin": "Неуспешна проверка на PIN.",
"newProfile": "Нов профил", "newProfile": "Нов профил",
"profileNameHint": "напр. Гости, Деца, Семейна стая", "profileNameHint": "напр. Гости, Деца, Семейна стая",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Forbindelse lånt.", "borrowConnectionBorrowed": "Forbindelse lånt.",
"borrowFailed": "Kunne ikke låne forbindelse.", "borrowFailed": "Kunne ikke låne forbindelse.",
"incorrectPin": "Forkert PIN.", "incorrectPin": "Forkert PIN.",
"incorrectPinTryAgain": "Forkert PIN. Prøv igen.",
"sourceProfileMissingParentAccount": "Kildeprofilen mangler sin overordnede konto.", "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.", "failedToVerifyPin": "Kunne ikke bekræfte PIN.",
"newProfile": "Ny profil", "newProfile": "Ny profil",
"profileNameHint": "fx. Gæster, Børn, Familiens stue", "profileNameHint": "fx. Gæster, Børn, Familiens stue",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Verbindung ausgeliehen.", "borrowConnectionBorrowed": "Verbindung ausgeliehen.",
"borrowFailed": "Verbindung konnte nicht ausgeliehen werden.", "borrowFailed": "Verbindung konnte nicht ausgeliehen werden.",
"incorrectPin": "Falsche PIN.", "incorrectPin": "Falsche PIN.",
"incorrectPinTryAgain": "Falsche PIN. Bitte erneut versuchen.",
"sourceProfileMissingParentAccount": "Dem Quellprofil fehlt das übergeordnete Konto.", "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.", "failedToVerifyPin": "PIN konnte nicht verifiziert werden.",
"newProfile": "Neues Profil", "newProfile": "Neues Profil",
"profileNameHint": "z. B. Gäste, Kinder, Wohnzimmer", "profileNameHint": "z. B. Gäste, Kinder, Wohnzimmer",
+2
View File
@@ -642,7 +642,9 @@
"borrowConnectionBorrowed": "Connection borrowed.", "borrowConnectionBorrowed": "Connection borrowed.",
"borrowFailed": "Failed to borrow connection.", "borrowFailed": "Failed to borrow connection.",
"incorrectPin": "Incorrect PIN.", "incorrectPin": "Incorrect PIN.",
"incorrectPinTryAgain": "Incorrect PIN. Please try again.",
"sourceProfileMissingParentAccount": "Source profile is missing its parent account.", "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.", "failedToVerifyPin": "Failed to verify PIN.",
"newProfile": "New profile", "newProfile": "New profile",
"profileNameHint": "e.g. Guests, Kids, Family Room", "profileNameHint": "e.g. Guests, Kids, Family Room",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Conexión tomada prestada.", "borrowConnectionBorrowed": "Conexión tomada prestada.",
"borrowFailed": "No se pudo tomar prestada la conexión.", "borrowFailed": "No se pudo tomar prestada la conexión.",
"incorrectPin": "PIN incorrecto.", "incorrectPin": "PIN incorrecto.",
"incorrectPinTryAgain": "PIN incorrecto. Inténtalo de nuevo.",
"sourceProfileMissingParentAccount": "Al perfil de origen le falta su cuenta principal.", "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.", "failedToVerifyPin": "No se pudo verificar el PIN.",
"newProfile": "Nuevo perfil", "newProfile": "Nuevo perfil",
"profileNameHint": "p. ej. Invitados, Niños, Sala familiar", "profileNameHint": "p. ej. Invitados, Niños, Sala familiar",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Connexion empruntée.", "borrowConnectionBorrowed": "Connexion empruntée.",
"borrowFailed": "Impossible d'emprunter la connexion.", "borrowFailed": "Impossible d'emprunter la connexion.",
"incorrectPin": "PIN incorrect.", "incorrectPin": "PIN incorrect.",
"incorrectPinTryAgain": "PIN incorrect. Veuillez réessayer.",
"sourceProfileMissingParentAccount": "Le profil source n'a pas son compte parent.", "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.", "failedToVerifyPin": "Impossible de vérifier le PIN.",
"newProfile": "Nouveau profil", "newProfile": "Nouveau profil",
"profileNameHint": "ex. Invités, Enfants, Salon familial", "profileNameHint": "ex. Invités, Enfants, Salon familial",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Connessione presa in prestito.", "borrowConnectionBorrowed": "Connessione presa in prestito.",
"borrowFailed": "Impossibile prendere in prestito la connessione.", "borrowFailed": "Impossibile prendere in prestito la connessione.",
"incorrectPin": "PIN errato.", "incorrectPin": "PIN errato.",
"incorrectPinTryAgain": "PIN errato. Riprova.",
"sourceProfileMissingParentAccount": "Al profilo di origine manca l'account principale.", "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.", "failedToVerifyPin": "Impossibile verificare il PIN.",
"newProfile": "Nuovo profilo", "newProfile": "Nuovo profilo",
"profileNameHint": "es. Ospiti, Bambini, Soggiorno", "profileNameHint": "es. Ospiti, Bambini, Soggiorno",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "接続を借用しました。", "borrowConnectionBorrowed": "接続を借用しました。",
"borrowFailed": "接続を借用できませんでした。", "borrowFailed": "接続を借用できませんでした。",
"incorrectPin": "PINが正しくありません。", "incorrectPin": "PINが正しくありません。",
"incorrectPinTryAgain": "PINが正しくありません。もう一度お試しください。",
"sourceProfileMissingParentAccount": "ソースプロフィールに親アカウントがありません。", "sourceProfileMissingParentAccount": "ソースプロフィールに親アカウントがありません。",
"failedToLoadHomeUsers": "Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。",
"failedToVerifyPin": "PINを確認できませんでした。", "failedToVerifyPin": "PINを確認できませんでした。",
"newProfile": "新しいプロファイル", "newProfile": "新しいプロファイル",
"profileNameHint": "例:ゲスト、キッズ、ファミリールーム", "profileNameHint": "例:ゲスト、キッズ、ファミリールーム",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "연결을 빌렸습니다.", "borrowConnectionBorrowed": "연결을 빌렸습니다.",
"borrowFailed": "연결을 빌리지 못했습니다.", "borrowFailed": "연결을 빌리지 못했습니다.",
"incorrectPin": "PIN이 올바르지 않습니다.", "incorrectPin": "PIN이 올바르지 않습니다.",
"incorrectPinTryAgain": "PIN이 올바르지 않습니다. 다시 시도하세요.",
"sourceProfileMissingParentAccount": "원본 프로필에 상위 계정이 없습니다.", "sourceProfileMissingParentAccount": "원본 프로필에 상위 계정이 없습니다.",
"failedToLoadHomeUsers": "Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"failedToVerifyPin": "PIN을 확인하지 못했습니다.", "failedToVerifyPin": "PIN을 확인하지 못했습니다.",
"newProfile": "새 프로필", "newProfile": "새 프로필",
"profileNameHint": "예: 손님, 어린이, 가족실", "profileNameHint": "예: 손님, 어린이, 가족실",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Tilkobling lånt.", "borrowConnectionBorrowed": "Tilkobling lånt.",
"borrowFailed": "Kunne ikke låne tilkoblingen.", "borrowFailed": "Kunne ikke låne tilkoblingen.",
"incorrectPin": "Feil PIN.", "incorrectPin": "Feil PIN.",
"incorrectPinTryAgain": "Feil PIN. Prøv igjen.",
"sourceProfileMissingParentAccount": "Kildeprofilen mangler foreldrekontoen sin.", "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.", "failedToVerifyPin": "Kunne ikke bekrefte PIN.",
"newProfile": "Ny profil", "newProfile": "Ny profil",
"profileNameHint": "f.eks. Gjester, Barn, Familierom", "profileNameHint": "f.eks. Gjester, Barn, Familierom",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Verbinding geleend.", "borrowConnectionBorrowed": "Verbinding geleend.",
"borrowFailed": "Kan verbinding niet lenen.", "borrowFailed": "Kan verbinding niet lenen.",
"incorrectPin": "Onjuiste PIN.", "incorrectPin": "Onjuiste PIN.",
"incorrectPinTryAgain": "Onjuiste PIN. Probeer het opnieuw.",
"sourceProfileMissingParentAccount": "Het bronprofiel mist het bovenliggende account.", "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.", "failedToVerifyPin": "Kan PIN niet verifiëren.",
"newProfile": "Nieuw profiel", "newProfile": "Nieuw profiel",
"profileNameHint": "bijv. Gasten, Kinderen, Woonkamer", "profileNameHint": "bijv. Gasten, Kinderen, Woonkamer",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Połączenie pożyczone.", "borrowConnectionBorrowed": "Połączenie pożyczone.",
"borrowFailed": "Nie udało się pożyczyć połączenia.", "borrowFailed": "Nie udało się pożyczyć połączenia.",
"incorrectPin": "Nieprawidłowy PIN.", "incorrectPin": "Nieprawidłowy PIN.",
"incorrectPinTryAgain": "Nieprawidłowy PIN. Spróbuj ponownie.",
"sourceProfileMissingParentAccount": "Profil źródłowy nie ma konta nadrzędnego.", "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.", "failedToVerifyPin": "Nie udało się zweryfikować PIN-u.",
"newProfile": "Nowy profil", "newProfile": "Nowy profil",
"profileNameHint": "np. Goście, Dzieci, Salon", "profileNameHint": "np. Goście, Dzieci, Salon",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Conexão tomada emprestada.", "borrowConnectionBorrowed": "Conexão tomada emprestada.",
"borrowFailed": "Não foi possível tomar a conexão emprestada.", "borrowFailed": "Não foi possível tomar a conexão emprestada.",
"incorrectPin": "PIN incorreto.", "incorrectPin": "PIN incorreto.",
"incorrectPinTryAgain": "PIN incorreto. Tente novamente.",
"sourceProfileMissingParentAccount": "O perfil de origem não tem a conta principal.", "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.", "failedToVerifyPin": "Não foi possível verificar o PIN.",
"newProfile": "Novo perfil", "newProfile": "Novo perfil",
"profileNameHint": "ex.: Visitantes, Crianças, Sala de família", "profileNameHint": "ex.: Visitantes, Crianças, Sala de família",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Подключение заимствовано.", "borrowConnectionBorrowed": "Подключение заимствовано.",
"borrowFailed": "Не удалось заимствовать подключение.", "borrowFailed": "Не удалось заимствовать подключение.",
"incorrectPin": "Неверный PIN.", "incorrectPin": "Неверный PIN.",
"incorrectPinTryAgain": "Неверный PIN. Попробуйте ещё раз.",
"sourceProfileMissingParentAccount": "У исходного профиля отсутствует родительская учетная запись.", "sourceProfileMissingParentAccount": "У исходного профиля отсутствует родительская учетная запись.",
"failedToLoadHomeUsers": "Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.",
"failedToVerifyPin": "Не удалось проверить PIN.", "failedToVerifyPin": "Не удалось проверить PIN.",
"newProfile": "Новый профиль", "newProfile": "Новый профиль",
"profileNameHint": "например, Гости, Дети, Семейная комната", "profileNameHint": "например, Гости, Дети, Семейная комната",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 16 /// Locales: 16
/// Strings: 20473 (1279 per locale) /// Strings: 20505 (1281 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // 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 borrowConnectionBorrowed => 'Връзката е използвана.';
@override String get borrowFailed => 'Неуспешно използване на връзка.'; @override String get borrowFailed => 'Неуспешно използване на връзка.';
@override String get incorrectPin => 'Неправилен PIN.'; @override String get incorrectPin => 'Неправилен PIN.';
@override String get incorrectPinTryAgain => 'Неправилен PIN. Опитайте отново.';
@override String get sourceProfileMissingParentAccount => 'Изходният профил няма родителски акаунт.'; @override String get sourceProfileMissingParentAccount => 'Изходният профил няма родителски акаунт.';
@override String get failedToLoadHomeUsers => 'Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.';
@override String get failedToVerifyPin => 'Неуспешна проверка на PIN.'; @override String get failedToVerifyPin => 'Неуспешна проверка на PIN.';
@override String get newProfile => 'Нов профил'; @override String get newProfile => 'Нов профил';
@override String get profileNameHint => 'напр. Гости, Деца, Семейна стая'; @override String get profileNameHint => 'напр. Гости, Деца, Семейна стая';
@@ -2524,7 +2526,9 @@ extension on TranslationsBg {
'profiles.borrowConnectionBorrowed' => 'Връзката е използвана.', 'profiles.borrowConnectionBorrowed' => 'Връзката е използвана.',
'profiles.borrowFailed' => 'Неуспешно използване на връзка.', 'profiles.borrowFailed' => 'Неуспешно използване на връзка.',
'profiles.incorrectPin' => 'Неправилен PIN.', 'profiles.incorrectPin' => 'Неправилен PIN.',
'profiles.incorrectPinTryAgain' => 'Неправилен PIN. Опитайте отново.',
'profiles.sourceProfileMissingParentAccount' => 'Изходният профил няма родителски акаунт.', 'profiles.sourceProfileMissingParentAccount' => 'Изходният профил няма родителски акаунт.',
'profiles.failedToLoadHomeUsers' => 'Потребителите на Plex Home не можаха да бъдат заредени. Проверете връзката си и опитайте отново.',
'profiles.failedToVerifyPin' => 'Неуспешна проверка на PIN.', 'profiles.failedToVerifyPin' => 'Неуспешна проверка на PIN.',
'profiles.newProfile' => 'Нов профил', 'profiles.newProfile' => 'Нов профил',
'profiles.profileNameHint' => 'напр. Гости, Деца, Семейна стая', 'profiles.profileNameHint' => 'напр. Гости, Деца, Семейна стая',
@@ -2952,10 +2956,10 @@ extension on TranslationsBg {
'companionRemote.pairing.validationHostRequired' => 'Моля, въведете адрес на хоста', 'companionRemote.pairing.validationHostRequired' => 'Моля, въведете адрес на хоста',
'companionRemote.pairing.validationHostFormat' => 'Форматът трябва да е IP:port (напр. 192.168.1.100:48632)', 'companionRemote.pairing.validationHostFormat' => 'Форматът трябва да е IP:port (напр. 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Връзката изтече. Използвайте една и съща мрежа на двете устройства.', 'companionRemote.pairing.connectionTimedOut' => 'Връзката изтече. Използвайте една и съща мрежа на двете устройства.',
'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.',
'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'companionRemote.pairing.sessionNotFound' => 'Устройството не е намерено. Уверете се, че Plezy работи на хоста.',
'companionRemote.pairing.authFailed' => 'Удостоверяването е неуспешно. Двете устройства трябва да използват същия Plex акаунт.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Неуспешно свързване: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?', 'companionRemote.remote.disconnectConfirm' => 'Искате ли да прекъснете връзката с дистанционната сесия?',
'companionRemote.remote.reconnecting' => 'Повторно свързване...', '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 borrowConnectionBorrowed => 'Forbindelse lånt.';
@override String get borrowFailed => 'Kunne ikke låne forbindelse.'; @override String get borrowFailed => 'Kunne ikke låne forbindelse.';
@override String get incorrectPin => 'Forkert PIN.'; @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 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 failedToVerifyPin => 'Kunne ikke bekræfte PIN.';
@override String get newProfile => 'Ny profil'; @override String get newProfile => 'Ny profil';
@override String get profileNameHint => 'fx. Gæster, Børn, Familiens stue'; @override String get profileNameHint => 'fx. Gæster, Børn, Familiens stue';
@@ -2524,7 +2526,9 @@ extension on TranslationsDa {
'profiles.borrowConnectionBorrowed' => 'Forbindelse lånt.', 'profiles.borrowConnectionBorrowed' => 'Forbindelse lånt.',
'profiles.borrowFailed' => 'Kunne ikke låne forbindelse.', 'profiles.borrowFailed' => 'Kunne ikke låne forbindelse.',
'profiles.incorrectPin' => 'Forkert PIN.', 'profiles.incorrectPin' => 'Forkert PIN.',
'profiles.incorrectPinTryAgain' => 'Forkert PIN. Prøv igen.',
'profiles.sourceProfileMissingParentAccount' => 'Kildeprofilen mangler sin overordnede konto.', '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.failedToVerifyPin' => 'Kunne ikke bekræfte PIN.',
'profiles.newProfile' => 'Ny profil', 'profiles.newProfile' => 'Ny profil',
'profiles.profileNameHint' => 'fx. Gæster, Børn, Familiens stue', '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.validationHostRequired' => 'Angiv venligst værtsadresse',
'companionRemote.pairing.validationHostFormat' => 'Format skal være IP:port (f.eks. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke oprette forbindelse: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde fra fjernsessionen?', 'companionRemote.remote.disconnectConfirm' => 'Vil du afbryde fra fjernsessionen?',
'companionRemote.remote.reconnecting' => 'Genopretter forbindelse...', '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 borrowConnectionBorrowed => 'Verbindung ausgeliehen.';
@override String get borrowFailed => 'Verbindung konnte nicht ausgeliehen werden.'; @override String get borrowFailed => 'Verbindung konnte nicht ausgeliehen werden.';
@override String get incorrectPin => 'Falsche PIN.'; @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 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 failedToVerifyPin => 'PIN konnte nicht verifiziert werden.';
@override String get newProfile => 'Neues Profil'; @override String get newProfile => 'Neues Profil';
@override String get profileNameHint => 'z. B. Gäste, Kinder, Wohnzimmer'; @override String get profileNameHint => 'z. B. Gäste, Kinder, Wohnzimmer';
@@ -2524,7 +2526,9 @@ extension on TranslationsDe {
'profiles.borrowConnectionBorrowed' => 'Verbindung ausgeliehen.', 'profiles.borrowConnectionBorrowed' => 'Verbindung ausgeliehen.',
'profiles.borrowFailed' => 'Verbindung konnte nicht ausgeliehen werden.', 'profiles.borrowFailed' => 'Verbindung konnte nicht ausgeliehen werden.',
'profiles.incorrectPin' => 'Falsche PIN.', 'profiles.incorrectPin' => 'Falsche PIN.',
'profiles.incorrectPinTryAgain' => 'Falsche PIN. Bitte erneut versuchen.',
'profiles.sourceProfileMissingParentAccount' => 'Dem Quellprofil fehlt das übergeordnete Konto.', '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.failedToVerifyPin' => 'PIN konnte nicht verifiziert werden.',
'profiles.newProfile' => 'Neues Profil', 'profiles.newProfile' => 'Neues Profil',
'profiles.profileNameHint' => 'z. B. Gäste, Kinder, Wohnzimmer', 'profiles.profileNameHint' => 'z. B. Gäste, Kinder, Wohnzimmer',
@@ -2952,10 +2956,10 @@ extension on TranslationsDe {
'companionRemote.pairing.validationHostRequired' => 'Bitte Host-Adresse eingeben', 'companionRemote.pairing.validationHostRequired' => 'Bitte Host-Adresse eingeben',
'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?', 'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?',
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...', 'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
+10 -2
View File
@@ -1975,9 +1975,15 @@ class TranslationsProfilesEn {
/// en: 'Incorrect PIN.' /// en: 'Incorrect PIN.'
String get incorrectPin => '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.' /// en: 'Source profile is missing its parent account.'
String get sourceProfileMissingParentAccount => '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.' /// en: 'Failed to verify PIN.'
String get failedToVerifyPin => 'Failed to verify PIN.'; String get failedToVerifyPin => 'Failed to verify PIN.';
@@ -5121,7 +5127,9 @@ extension on Translations {
'profiles.borrowConnectionBorrowed' => 'Connection borrowed.', 'profiles.borrowConnectionBorrowed' => 'Connection borrowed.',
'profiles.borrowFailed' => 'Failed to borrow connection.', 'profiles.borrowFailed' => 'Failed to borrow connection.',
'profiles.incorrectPin' => 'Incorrect PIN.', 'profiles.incorrectPin' => 'Incorrect PIN.',
'profiles.incorrectPinTryAgain' => 'Incorrect PIN. Please try again.',
'profiles.sourceProfileMissingParentAccount' => 'Source profile is missing its parent account.', '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.failedToVerifyPin' => 'Failed to verify PIN.',
'profiles.newProfile' => 'New profile', 'profiles.newProfile' => 'New profile',
'profiles.profileNameHint' => 'e.g. Guests, Kids, Family Room', '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.discoveryDescription' => 'Plezy devices with the same Plex account appear here',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632', 'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
'companionRemote.pairing.connecting' => 'Connecting...', 'companionRemote.pairing.connecting' => 'Connecting...',
'companionRemote.pairing.searchingForDevices' => 'Looking for devices...',
'companionRemote.pairing.noDevicesFound' => 'No devices found on your network',
_ => null, _ => null,
} ?? switch (path) { } ?? 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.noDevicesHint' => 'Open Plezy on desktop and use the same WiFi',
'companionRemote.pairing.availableDevices' => 'Available Devices', 'companionRemote.pairing.availableDevices' => 'Available Devices',
'companionRemote.pairing.manualConnection' => 'Manual Connection', '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 borrowConnectionBorrowed => 'Conexión tomada prestada.';
@override String get borrowFailed => 'No se pudo tomar prestada la conexión.'; @override String get borrowFailed => 'No se pudo tomar prestada la conexión.';
@override String get incorrectPin => 'PIN incorrecto.'; @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 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 failedToVerifyPin => 'No se pudo verificar el PIN.';
@override String get newProfile => 'Nuevo perfil'; @override String get newProfile => 'Nuevo perfil';
@override String get profileNameHint => 'p. ej. Invitados, Niños, Sala familiar'; @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.borrowConnectionBorrowed' => 'Conexión tomada prestada.',
'profiles.borrowFailed' => 'No se pudo tomar prestada la conexión.', 'profiles.borrowFailed' => 'No se pudo tomar prestada la conexión.',
'profiles.incorrectPin' => 'PIN incorrecto.', 'profiles.incorrectPin' => 'PIN incorrecto.',
'profiles.incorrectPinTryAgain' => 'PIN incorrecto. Inténtalo de nuevo.',
'profiles.sourceProfileMissingParentAccount' => 'Al perfil de origen le falta su cuenta principal.', '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.failedToVerifyPin' => 'No se pudo verificar el PIN.',
'profiles.newProfile' => 'Nuevo perfil', 'profiles.newProfile' => 'Nuevo perfil',
'profiles.profileNameHint' => 'p. ej. Invitados, Niños, Sala familiar', '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.validationHostRequired' => 'Ingresa la dirección del host',
'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (ej. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}',
'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?', 'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...', '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 borrowConnectionBorrowed => 'Connexion empruntée.';
@override String get borrowFailed => 'Impossible d\'emprunter la connexion.'; @override String get borrowFailed => 'Impossible d\'emprunter la connexion.';
@override String get incorrectPin => 'PIN incorrect.'; @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 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 failedToVerifyPin => 'Impossible de vérifier le PIN.';
@override String get newProfile => 'Nouveau profil'; @override String get newProfile => 'Nouveau profil';
@override String get profileNameHint => 'ex. Invités, Enfants, Salon familial'; @override String get profileNameHint => 'ex. Invités, Enfants, Salon familial';
@@ -2524,7 +2526,9 @@ extension on TranslationsFr {
'profiles.borrowConnectionBorrowed' => 'Connexion empruntée.', 'profiles.borrowConnectionBorrowed' => 'Connexion empruntée.',
'profiles.borrowFailed' => 'Impossible d\'emprunter la connexion.', 'profiles.borrowFailed' => 'Impossible d\'emprunter la connexion.',
'profiles.incorrectPin' => 'PIN incorrect.', 'profiles.incorrectPin' => 'PIN incorrect.',
'profiles.incorrectPinTryAgain' => 'PIN incorrect. Veuillez réessayer.',
'profiles.sourceProfileMissingParentAccount' => 'Le profil source n\'a pas son compte parent.', '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.failedToVerifyPin' => 'Impossible de vérifier le PIN.',
'profiles.newProfile' => 'Nouveau profil', 'profiles.newProfile' => 'Nouveau profil',
'profiles.profileNameHint' => 'ex. Invités, Enfants, Salon familial', '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.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.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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}',
'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?', 'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?',
'companionRemote.remote.reconnecting' => 'Reconnexion...', '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 borrowConnectionBorrowed => 'Connessione presa in prestito.';
@override String get borrowFailed => 'Impossibile prendere in prestito la connessione.'; @override String get borrowFailed => 'Impossibile prendere in prestito la connessione.';
@override String get incorrectPin => 'PIN errato.'; @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 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 failedToVerifyPin => 'Impossibile verificare il PIN.';
@override String get newProfile => 'Nuovo profilo'; @override String get newProfile => 'Nuovo profilo';
@override String get profileNameHint => 'es. Ospiti, Bambini, Soggiorno'; @override String get profileNameHint => 'es. Ospiti, Bambini, Soggiorno';
@@ -2524,7 +2526,9 @@ extension on TranslationsIt {
'profiles.borrowConnectionBorrowed' => 'Connessione presa in prestito.', 'profiles.borrowConnectionBorrowed' => 'Connessione presa in prestito.',
'profiles.borrowFailed' => 'Impossibile prendere in prestito la connessione.', 'profiles.borrowFailed' => 'Impossibile prendere in prestito la connessione.',
'profiles.incorrectPin' => 'PIN errato.', 'profiles.incorrectPin' => 'PIN errato.',
'profiles.incorrectPinTryAgain' => 'PIN errato. Riprova.',
'profiles.sourceProfileMissingParentAccount' => 'Al profilo di origine manca l\'account principale.', '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.failedToVerifyPin' => 'Impossibile verificare il PIN.',
'profiles.newProfile' => 'Nuovo profilo', 'profiles.newProfile' => 'Nuovo profilo',
'profiles.profileNameHint' => 'es. Ospiti, Bambini, Soggiorno', 'profiles.profileNameHint' => 'es. Ospiti, Bambini, Soggiorno',
@@ -2952,10 +2956,10 @@ extension on TranslationsIt {
'companionRemote.pairing.validationHostRequired' => 'Inserisci l\'indirizzo host', 'companionRemote.pairing.validationHostRequired' => 'Inserisci l\'indirizzo host',
'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Connessione fallita: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?', 'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?',
'companionRemote.remote.reconnecting' => 'Riconnessione...', 'companionRemote.remote.reconnecting' => 'Riconnessione...',
+6 -2
View File
@@ -829,7 +829,9 @@ class _TranslationsProfilesJa extends TranslationsProfilesEn {
@override String get borrowConnectionBorrowed => '接続を借用しました。'; @override String get borrowConnectionBorrowed => '接続を借用しました。';
@override String get borrowFailed => '接続を借用できませんでした。'; @override String get borrowFailed => '接続を借用できませんでした。';
@override String get incorrectPin => 'PINが正しくありません。'; @override String get incorrectPin => 'PINが正しくありません。';
@override String get incorrectPinTryAgain => 'PINが正しくありません。もう一度お試しください。';
@override String get sourceProfileMissingParentAccount => 'ソースプロフィールに親アカウントがありません。'; @override String get sourceProfileMissingParentAccount => 'ソースプロフィールに親アカウントがありません。';
@override String get failedToLoadHomeUsers => 'Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。';
@override String get failedToVerifyPin => 'PINを確認できませんでした。'; @override String get failedToVerifyPin => 'PINを確認できませんでした。';
@override String get newProfile => '新しいプロファイル'; @override String get newProfile => '新しいプロファイル';
@override String get profileNameHint => '例:ゲスト、キッズ、ファミリールーム'; @override String get profileNameHint => '例:ゲスト、キッズ、ファミリールーム';
@@ -2524,7 +2526,9 @@ extension on TranslationsJa {
'profiles.borrowConnectionBorrowed' => '接続を借用しました。', 'profiles.borrowConnectionBorrowed' => '接続を借用しました。',
'profiles.borrowFailed' => '接続を借用できませんでした。', 'profiles.borrowFailed' => '接続を借用できませんでした。',
'profiles.incorrectPin' => 'PINが正しくありません。', 'profiles.incorrectPin' => 'PINが正しくありません。',
'profiles.incorrectPinTryAgain' => 'PINが正しくありません。もう一度お試しください。',
'profiles.sourceProfileMissingParentAccount' => 'ソースプロフィールに親アカウントがありません。', 'profiles.sourceProfileMissingParentAccount' => 'ソースプロフィールに親アカウントがありません。',
'profiles.failedToLoadHomeUsers' => 'Plex Homeユーザーを読み込めませんでした。接続を確認して、もう一度お試しください。',
'profiles.failedToVerifyPin' => 'PINを確認できませんでした。', 'profiles.failedToVerifyPin' => 'PINを確認できませんでした。',
'profiles.newProfile' => '新しいプロファイル', 'profiles.newProfile' => '新しいプロファイル',
'profiles.profileNameHint' => '例:ゲスト、キッズ、ファミリールーム', 'profiles.profileNameHint' => '例:ゲスト、キッズ、ファミリールーム',
@@ -2952,10 +2956,10 @@ extension on TranslationsJa {
'companionRemote.pairing.validationHostRequired' => 'ホストアドレスを入力してください', 'companionRemote.pairing.validationHostRequired' => 'ホストアドレスを入力してください',
'companionRemote.pairing.validationHostFormat' => '形式はIP:ポートである必要があります(例: 192.168.1.100:48632', 'companionRemote.pairing.validationHostFormat' => '形式はIP:ポートである必要があります(例: 192.168.1.100:48632',
'companionRemote.pairing.connectionTimedOut' => '接続がタイムアウトしました。両方のデバイスで同じネットワークを使用してください。', 'companionRemote.pairing.connectionTimedOut' => '接続がタイムアウトしました。両方のデバイスで同じネットワークを使用してください。',
'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。',
'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'companionRemote.pairing.sessionNotFound' => 'デバイスが見つかりません。ホストでPlezyが実行中か確認してください。',
'companionRemote.pairing.authFailed' => '認証に失敗しました。両方のデバイスで同じPlexアカウントが必要です。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '接続に失敗しました: ${error}',
'companionRemote.remote.disconnectConfirm' => 'リモートセッションから切断しますか?', 'companionRemote.remote.disconnectConfirm' => 'リモートセッションから切断しますか?',
'companionRemote.remote.reconnecting' => '再接続中...', 'companionRemote.remote.reconnecting' => '再接続中...',
+6 -2
View File
@@ -829,7 +829,9 @@ class _TranslationsProfilesKo extends TranslationsProfilesEn {
@override String get borrowConnectionBorrowed => '연결을 빌렸습니다.'; @override String get borrowConnectionBorrowed => '연결을 빌렸습니다.';
@override String get borrowFailed => '연결을 빌리지 못했습니다.'; @override String get borrowFailed => '연결을 빌리지 못했습니다.';
@override String get incorrectPin => 'PIN이 올바르지 않습니다.'; @override String get incorrectPin => 'PIN이 올바르지 않습니다.';
@override String get incorrectPinTryAgain => 'PIN이 올바르지 않습니다. 다시 시도하세요.';
@override String get sourceProfileMissingParentAccount => '원본 프로필에 상위 계정이 없습니다.'; @override String get sourceProfileMissingParentAccount => '원본 프로필에 상위 계정이 없습니다.';
@override String get failedToLoadHomeUsers => 'Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.';
@override String get failedToVerifyPin => 'PIN을 확인하지 못했습니다.'; @override String get failedToVerifyPin => 'PIN을 확인하지 못했습니다.';
@override String get newProfile => '새 프로필'; @override String get newProfile => '새 프로필';
@override String get profileNameHint => '예: 손님, 어린이, 가족실'; @override String get profileNameHint => '예: 손님, 어린이, 가족실';
@@ -2524,7 +2526,9 @@ extension on TranslationsKo {
'profiles.borrowConnectionBorrowed' => '연결을 빌렸습니다.', 'profiles.borrowConnectionBorrowed' => '연결을 빌렸습니다.',
'profiles.borrowFailed' => '연결을 빌리지 못했습니다.', 'profiles.borrowFailed' => '연결을 빌리지 못했습니다.',
'profiles.incorrectPin' => 'PIN이 올바르지 않습니다.', 'profiles.incorrectPin' => 'PIN이 올바르지 않습니다.',
'profiles.incorrectPinTryAgain' => 'PIN이 올바르지 않습니다. 다시 시도하세요.',
'profiles.sourceProfileMissingParentAccount' => '원본 프로필에 상위 계정이 없습니다.', 'profiles.sourceProfileMissingParentAccount' => '원본 프로필에 상위 계정이 없습니다.',
'profiles.failedToLoadHomeUsers' => 'Plex Home 사용자를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.',
'profiles.failedToVerifyPin' => 'PIN을 확인하지 못했습니다.', 'profiles.failedToVerifyPin' => 'PIN을 확인하지 못했습니다.',
'profiles.newProfile' => '새 프로필', 'profiles.newProfile' => '새 프로필',
'profiles.profileNameHint' => '예: 손님, 어린이, 가족실', 'profiles.profileNameHint' => '예: 손님, 어린이, 가족실',
@@ -2952,10 +2956,10 @@ extension on TranslationsKo {
'companionRemote.pairing.validationHostRequired' => '호스트 주소를 입력하세요', 'companionRemote.pairing.validationHostRequired' => '호스트 주소를 입력하세요',
'companionRemote.pairing.validationHostFormat' => '형식은 IP:포트여야 합니다 (예: 192.168.1.100:48632)', 'companionRemote.pairing.validationHostFormat' => '형식은 IP:포트여야 합니다 (예: 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 두 기기에서 같은 네트워크를 사용하세요.', 'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 두 기기에서 같은 네트워크를 사용하세요.',
'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.',
'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'companionRemote.pairing.sessionNotFound' => '기기를 찾을 수 없습니다. 호스트에서 Plezy가 실행 중인지 확인하세요.',
'companionRemote.pairing.authFailed' => '인증에 실패했습니다. 두 기기 모두 같은 Plex 계정이 필요합니다.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}',
'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?', 'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?',
'companionRemote.remote.reconnecting' => '재연결 중...', '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 borrowConnectionBorrowed => 'Tilkobling lånt.';
@override String get borrowFailed => 'Kunne ikke låne tilkoblingen.'; @override String get borrowFailed => 'Kunne ikke låne tilkoblingen.';
@override String get incorrectPin => 'Feil PIN.'; @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 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 failedToVerifyPin => 'Kunne ikke bekrefte PIN.';
@override String get newProfile => 'Ny profil'; @override String get newProfile => 'Ny profil';
@override String get profileNameHint => 'f.eks. Gjester, Barn, Familierom'; @override String get profileNameHint => 'f.eks. Gjester, Barn, Familierom';
@@ -2524,7 +2526,9 @@ extension on TranslationsNb {
'profiles.borrowConnectionBorrowed' => 'Tilkobling lånt.', 'profiles.borrowConnectionBorrowed' => 'Tilkobling lånt.',
'profiles.borrowFailed' => 'Kunne ikke låne tilkoblingen.', 'profiles.borrowFailed' => 'Kunne ikke låne tilkoblingen.',
'profiles.incorrectPin' => 'Feil PIN.', 'profiles.incorrectPin' => 'Feil PIN.',
'profiles.incorrectPinTryAgain' => 'Feil PIN. Prøv igjen.',
'profiles.sourceProfileMissingParentAccount' => 'Kildeprofilen mangler foreldrekontoen sin.', '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.failedToVerifyPin' => 'Kunne ikke bekrefte PIN.',
'profiles.newProfile' => 'Ny profil', 'profiles.newProfile' => 'Ny profil',
'profiles.profileNameHint' => 'f.eks. Gjester, Barn, Familierom', 'profiles.profileNameHint' => 'f.eks. Gjester, Barn, Familierom',
@@ -2952,10 +2956,10 @@ extension on TranslationsNb {
'companionRemote.pairing.validationHostRequired' => 'Vennligst oppgi vertsadresse', 'companionRemote.pairing.validationHostRequired' => 'Vennligst oppgi vertsadresse',
'companionRemote.pairing.validationHostFormat' => 'Format må være IP:port (f.eks. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Kunne ikke koble til: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?', 'companionRemote.remote.disconnectConfirm' => 'Vil du koble fra fjernøkten?',
'companionRemote.remote.reconnecting' => 'Kobler til på nytt...', '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 borrowConnectionBorrowed => 'Verbinding geleend.';
@override String get borrowFailed => 'Kan verbinding niet lenen.'; @override String get borrowFailed => 'Kan verbinding niet lenen.';
@override String get incorrectPin => 'Onjuiste PIN.'; @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 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 failedToVerifyPin => 'Kan PIN niet verifiëren.';
@override String get newProfile => 'Nieuw profiel'; @override String get newProfile => 'Nieuw profiel';
@override String get profileNameHint => 'bijv. Gasten, Kinderen, Woonkamer'; @override String get profileNameHint => 'bijv. Gasten, Kinderen, Woonkamer';
@@ -2524,7 +2526,9 @@ extension on TranslationsNl {
'profiles.borrowConnectionBorrowed' => 'Verbinding geleend.', 'profiles.borrowConnectionBorrowed' => 'Verbinding geleend.',
'profiles.borrowFailed' => 'Kan verbinding niet lenen.', 'profiles.borrowFailed' => 'Kan verbinding niet lenen.',
'profiles.incorrectPin' => 'Onjuiste PIN.', 'profiles.incorrectPin' => 'Onjuiste PIN.',
'profiles.incorrectPinTryAgain' => 'Onjuiste PIN. Probeer het opnieuw.',
'profiles.sourceProfileMissingParentAccount' => 'Het bronprofiel mist het bovenliggende account.', '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.failedToVerifyPin' => 'Kan PIN niet verifiëren.',
'profiles.newProfile' => 'Nieuw profiel', 'profiles.newProfile' => 'Nieuw profiel',
'profiles.profileNameHint' => 'bijv. Gasten, Kinderen, Woonkamer', 'profiles.profileNameHint' => 'bijv. Gasten, Kinderen, Woonkamer',
@@ -2952,10 +2956,10 @@ extension on TranslationsNl {
'companionRemote.pairing.validationHostRequired' => 'Voer het hostadres in', 'companionRemote.pairing.validationHostRequired' => 'Voer het hostadres in',
'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Kan niet verbinden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?', 'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?',
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...', '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 borrowConnectionBorrowed => 'Połączenie pożyczone.';
@override String get borrowFailed => 'Nie udało się pożyczyć połączenia.'; @override String get borrowFailed => 'Nie udało się pożyczyć połączenia.';
@override String get incorrectPin => 'Nieprawidłowy PIN.'; @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 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 failedToVerifyPin => 'Nie udało się zweryfikować PIN-u.';
@override String get newProfile => 'Nowy profil'; @override String get newProfile => 'Nowy profil';
@override String get profileNameHint => 'np. Goście, Dzieci, Salon'; @override String get profileNameHint => 'np. Goście, Dzieci, Salon';
@@ -2524,7 +2526,9 @@ extension on TranslationsPl {
'profiles.borrowConnectionBorrowed' => 'Połączenie pożyczone.', 'profiles.borrowConnectionBorrowed' => 'Połączenie pożyczone.',
'profiles.borrowFailed' => 'Nie udało się pożyczyć połączenia.', 'profiles.borrowFailed' => 'Nie udało się pożyczyć połączenia.',
'profiles.incorrectPin' => 'Nieprawidłowy PIN.', '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.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.failedToVerifyPin' => 'Nie udało się zweryfikować PIN-u.',
'profiles.newProfile' => 'Nowy profil', 'profiles.newProfile' => 'Nowy profil',
'profiles.profileNameHint' => 'np. Goście, Dzieci, Salon', 'profiles.profileNameHint' => 'np. Goście, Dzieci, Salon',
@@ -2952,10 +2956,10 @@ extension on TranslationsPl {
'companionRemote.pairing.validationHostRequired' => 'Wprowadź adres hosta', 'companionRemote.pairing.validationHostRequired' => 'Wprowadź adres hosta',
'companionRemote.pairing.validationHostFormat' => 'Format musi być IP:port (np. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.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.disconnectConfirm' => 'Czy chcesz się rozłączyć od sesji zdalnej?',
'companionRemote.remote.reconnecting' => 'Ponowne łączenie...', '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 borrowConnectionBorrowed => 'Conexão tomada emprestada.';
@override String get borrowFailed => 'Não foi possível tomar a conexão emprestada.'; @override String get borrowFailed => 'Não foi possível tomar a conexão emprestada.';
@override String get incorrectPin => 'PIN incorreto.'; @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 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 failedToVerifyPin => 'Não foi possível verificar o PIN.';
@override String get newProfile => 'Novo perfil'; @override String get newProfile => 'Novo perfil';
@override String get profileNameHint => 'ex.: Visitantes, Crianças, Sala de família'; @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.borrowConnectionBorrowed' => 'Conexão tomada emprestada.',
'profiles.borrowFailed' => 'Não foi possível tomar a conexão emprestada.', 'profiles.borrowFailed' => 'Não foi possível tomar a conexão emprestada.',
'profiles.incorrectPin' => 'PIN incorreto.', 'profiles.incorrectPin' => 'PIN incorreto.',
'profiles.incorrectPinTryAgain' => 'PIN incorreto. Tente novamente.',
'profiles.sourceProfileMissingParentAccount' => 'O perfil de origem não tem a conta principal.', '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.failedToVerifyPin' => 'Não foi possível verificar o PIN.',
'profiles.newProfile' => 'Novo perfil', 'profiles.newProfile' => 'Novo perfil',
'profiles.profileNameHint' => 'ex.: Visitantes, Crianças, Sala de família', '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.validationHostRequired' => 'Introduza o endereço do host',
'companionRemote.pairing.validationHostFormat' => 'O formato deve ser IP:porta (ex. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Falha ao conectar: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?', 'companionRemote.remote.disconnectConfirm' => 'Deseja desconectar da sessão remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...', 'companionRemote.remote.reconnecting' => 'Reconectando...',
+6 -2
View File
@@ -829,7 +829,9 @@ class _TranslationsProfilesRu extends TranslationsProfilesEn {
@override String get borrowConnectionBorrowed => 'Подключение заимствовано.'; @override String get borrowConnectionBorrowed => 'Подключение заимствовано.';
@override String get borrowFailed => 'Не удалось заимствовать подключение.'; @override String get borrowFailed => 'Не удалось заимствовать подключение.';
@override String get incorrectPin => 'Неверный PIN.'; @override String get incorrectPin => 'Неверный PIN.';
@override String get incorrectPinTryAgain => 'Неверный PIN. Попробуйте ещё раз.';
@override String get sourceProfileMissingParentAccount => 'У исходного профиля отсутствует родительская учетная запись.'; @override String get sourceProfileMissingParentAccount => 'У исходного профиля отсутствует родительская учетная запись.';
@override String get failedToLoadHomeUsers => 'Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.';
@override String get failedToVerifyPin => 'Не удалось проверить PIN.'; @override String get failedToVerifyPin => 'Не удалось проверить PIN.';
@override String get newProfile => 'Новый профиль'; @override String get newProfile => 'Новый профиль';
@override String get profileNameHint => 'например, Гости, Дети, Семейная комната'; @override String get profileNameHint => 'например, Гости, Дети, Семейная комната';
@@ -2524,7 +2526,9 @@ extension on TranslationsRu {
'profiles.borrowConnectionBorrowed' => 'Подключение заимствовано.', 'profiles.borrowConnectionBorrowed' => 'Подключение заимствовано.',
'profiles.borrowFailed' => 'Не удалось заимствовать подключение.', 'profiles.borrowFailed' => 'Не удалось заимствовать подключение.',
'profiles.incorrectPin' => 'Неверный PIN.', 'profiles.incorrectPin' => 'Неверный PIN.',
'profiles.incorrectPinTryAgain' => 'Неверный PIN. Попробуйте ещё раз.',
'profiles.sourceProfileMissingParentAccount' => 'У исходного профиля отсутствует родительская учетная запись.', 'profiles.sourceProfileMissingParentAccount' => 'У исходного профиля отсутствует родительская учетная запись.',
'profiles.failedToLoadHomeUsers' => 'Не удалось загрузить пользователей Plex Home. Проверьте подключение и попробуйте ещё раз.',
'profiles.failedToVerifyPin' => 'Не удалось проверить PIN.', 'profiles.failedToVerifyPin' => 'Не удалось проверить PIN.',
'profiles.newProfile' => 'Новый профиль', 'profiles.newProfile' => 'Новый профиль',
'profiles.profileNameHint' => 'например, Гости, Дети, Семейная комната', 'profiles.profileNameHint' => 'например, Гости, Дети, Семейная комната',
@@ -2952,10 +2956,10 @@ extension on TranslationsRu {
'companionRemote.pairing.validationHostRequired' => 'Введите адрес хоста', 'companionRemote.pairing.validationHostRequired' => 'Введите адрес хоста',
'companionRemote.pairing.validationHostFormat' => 'Формат должен быть IP:порт (например, 192.168.1.100:48632)', 'companionRemote.pairing.validationHostFormat' => 'Формат должен быть IP:порт (например, 192.168.1.100:48632)',
'companionRemote.pairing.connectionTimedOut' => 'Время подключения истекло. Используйте одну сеть на обоих устройствах.', 'companionRemote.pairing.connectionTimedOut' => 'Время подключения истекло. Используйте одну сеть на обоих устройствах.',
'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.',
'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'companionRemote.pairing.sessionNotFound' => 'Устройство не найдено. Убедитесь, что Plezy запущен на хосте.',
'companionRemote.pairing.authFailed' => 'Аутентификация не удалась. На обоих устройствах нужен один аккаунт Plex.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Не удалось подключиться: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?', 'companionRemote.remote.disconnectConfirm' => 'Отключиться от удалённой сессии?',
'companionRemote.remote.reconnecting' => 'Переподключение...', '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 borrowConnectionBorrowed => 'Anslutning lånad.';
@override String get borrowFailed => 'Kunde inte låna anslutningen.'; @override String get borrowFailed => 'Kunde inte låna anslutningen.';
@override String get incorrectPin => 'Fel PIN.'; @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 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 failedToVerifyPin => 'Kunde inte verifiera PIN.';
@override String get newProfile => 'Ny profil'; @override String get newProfile => 'Ny profil';
@override String get profileNameHint => 't.ex. Gäster, Barn, Familjerum'; @override String get profileNameHint => 't.ex. Gäster, Barn, Familjerum';
@@ -2524,7 +2526,9 @@ extension on TranslationsSv {
'profiles.borrowConnectionBorrowed' => 'Anslutning lånad.', 'profiles.borrowConnectionBorrowed' => 'Anslutning lånad.',
'profiles.borrowFailed' => 'Kunde inte låna anslutningen.', 'profiles.borrowFailed' => 'Kunde inte låna anslutningen.',
'profiles.incorrectPin' => 'Fel PIN.', 'profiles.incorrectPin' => 'Fel PIN.',
'profiles.incorrectPinTryAgain' => 'Fel PIN. Försök igen.',
'profiles.sourceProfileMissingParentAccount' => 'Källprofilen saknar sitt överordnade konto.', '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.failedToVerifyPin' => 'Kunde inte verifiera PIN.',
'profiles.newProfile' => 'Ny profil', 'profiles.newProfile' => 'Ny profil',
'profiles.profileNameHint' => 't.ex. Gäster, Barn, Familjerum', 'profiles.profileNameHint' => 't.ex. Gäster, Barn, Familjerum',
@@ -2952,10 +2956,10 @@ extension on TranslationsSv {
'companionRemote.pairing.validationHostRequired' => 'Ange värdadress', 'companionRemote.pairing.validationHostRequired' => 'Ange värdadress',
'companionRemote.pairing.validationHostFormat' => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)', '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.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, _ => null,
} ?? switch (path) { } ?? 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.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?', 'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?',
'companionRemote.remote.reconnecting' => 'Återansluter...', 'companionRemote.remote.reconnecting' => 'Återansluter...',
+6 -2
View File
@@ -829,7 +829,9 @@ class _TranslationsProfilesZh extends TranslationsProfilesEn {
@override String get borrowConnectionBorrowed => '已借用连接。'; @override String get borrowConnectionBorrowed => '已借用连接。';
@override String get borrowFailed => '无法借用连接。'; @override String get borrowFailed => '无法借用连接。';
@override String get incorrectPin => 'PIN 不正确。'; @override String get incorrectPin => 'PIN 不正确。';
@override String get incorrectPinTryAgain => 'PIN 不正确。请重试。';
@override String get sourceProfileMissingParentAccount => '源个人资料缺少其父账号。'; @override String get sourceProfileMissingParentAccount => '源个人资料缺少其父账号。';
@override String get failedToLoadHomeUsers => '无法加载您的 Plex Home 用户。请检查网络连接后重试。';
@override String get failedToVerifyPin => '无法验证 PIN。'; @override String get failedToVerifyPin => '无法验证 PIN。';
@override String get newProfile => '新建配置文件'; @override String get newProfile => '新建配置文件';
@override String get profileNameHint => '例如:访客、儿童、家庭房'; @override String get profileNameHint => '例如:访客、儿童、家庭房';
@@ -2524,7 +2526,9 @@ extension on TranslationsZh {
'profiles.borrowConnectionBorrowed' => '已借用连接。', 'profiles.borrowConnectionBorrowed' => '已借用连接。',
'profiles.borrowFailed' => '无法借用连接。', 'profiles.borrowFailed' => '无法借用连接。',
'profiles.incorrectPin' => 'PIN 不正确。', 'profiles.incorrectPin' => 'PIN 不正确。',
'profiles.incorrectPinTryAgain' => 'PIN 不正确。请重试。',
'profiles.sourceProfileMissingParentAccount' => '源个人资料缺少其父账号。', 'profiles.sourceProfileMissingParentAccount' => '源个人资料缺少其父账号。',
'profiles.failedToLoadHomeUsers' => '无法加载您的 Plex Home 用户。请检查网络连接后重试。',
'profiles.failedToVerifyPin' => '无法验证 PIN。', 'profiles.failedToVerifyPin' => '无法验证 PIN。',
'profiles.newProfile' => '新建配置文件', 'profiles.newProfile' => '新建配置文件',
'profiles.profileNameHint' => '例如:访客、儿童、家庭房', 'profiles.profileNameHint' => '例如:访客、儿童、家庭房',
@@ -2952,10 +2956,10 @@ extension on TranslationsZh {
'companionRemote.pairing.validationHostRequired' => '请输入主机地址', 'companionRemote.pairing.validationHostRequired' => '请输入主机地址',
'companionRemote.pairing.validationHostFormat' => '格式必须为IP:端口(例如 192.168.1.100:48632', 'companionRemote.pairing.validationHostFormat' => '格式必须为IP:端口(例如 192.168.1.100:48632',
'companionRemote.pairing.connectionTimedOut' => '连接超时。请在两台设备上使用同一网络。', 'companionRemote.pairing.connectionTimedOut' => '连接超时。请在两台设备上使用同一网络。',
'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。',
'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'companionRemote.pairing.sessionNotFound' => '未找到设备。请确认 Plezy 正在主机上运行。',
'companionRemote.pairing.authFailed' => '认证失败。两台设备需要使用同一 Plex 账号。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}', 'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}',
'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?', 'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?',
'companionRemote.remote.reconnecting' => '重新连接中...', 'companionRemote.remote.reconnecting' => '重新连接中...',
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "Anslutning lånad.", "borrowConnectionBorrowed": "Anslutning lånad.",
"borrowFailed": "Kunde inte låna anslutningen.", "borrowFailed": "Kunde inte låna anslutningen.",
"incorrectPin": "Fel PIN.", "incorrectPin": "Fel PIN.",
"incorrectPinTryAgain": "Fel PIN. Försök igen.",
"sourceProfileMissingParentAccount": "Källprofilen saknar sitt överordnade konto.", "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.", "failedToVerifyPin": "Kunde inte verifiera PIN.",
"newProfile": "Ny profil", "newProfile": "Ny profil",
"profileNameHint": "t.ex. Gäster, Barn, Familjerum", "profileNameHint": "t.ex. Gäster, Barn, Familjerum",
+2
View File
@@ -636,7 +636,9 @@
"borrowConnectionBorrowed": "已借用连接。", "borrowConnectionBorrowed": "已借用连接。",
"borrowFailed": "无法借用连接。", "borrowFailed": "无法借用连接。",
"incorrectPin": "PIN 不正确。", "incorrectPin": "PIN 不正确。",
"incorrectPinTryAgain": "PIN 不正确。请重试。",
"sourceProfileMissingParentAccount": "源个人资料缺少其父账号。", "sourceProfileMissingParentAccount": "源个人资料缺少其父账号。",
"failedToLoadHomeUsers": "无法加载您的 Plex Home 用户。请检查网络连接后重试。",
"failedToVerifyPin": "无法验证 PIN。", "failedToVerifyPin": "无法验证 PIN。",
"newProfile": "新建配置文件", "newProfile": "新建配置文件",
"profileNameHint": "例如:访客、儿童、家庭房", "profileNameHint": "例如:访客、儿童、家庭房",
+10 -5
View File
@@ -168,12 +168,15 @@ class PlexHomeService {
} }
/// Force-refresh a single account. Useful after sign-in / borrow flows. /// 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) { if (conn.accountToken.isEmpty) {
appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token'); appLogger.w('PlexHomeService: skipping fetch for ${conn.accountLabel} (${conn.id}) — empty token');
return; return false;
} }
final storage = _storage ?? await StorageService.getInstance(); final storage = _storage ?? await StorageService.getInstance();
_storage = storage; _storage = storage;
@@ -184,7 +187,7 @@ class PlexHomeService {
// as ghosts until the next removal event. // as ghosts until the next removal event.
if (await _connections.get(conn.id) == null) { if (await _connections.get(conn.id) == null) {
appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}'); appLogger.d('PlexHomeService: dropping fetch result for removed account ${conn.accountLabel}');
return; return false;
} }
final encoded = users.map((u) => u.toJson()).toList(); final encoded = users.map((u) => u.toJson()).toList();
// Unchanged fetches (the hourly ticker, mostly) must not emit: every // Unchanged fetches (the hourly ticker, mostly) must not emit: every
@@ -192,14 +195,16 @@ class PlexHomeService {
// recompute/notify cascade across the app. // recompute/notify cascade across the app.
if (_byConnection.containsKey(conn.id) && storage.getPlexHomeUsersCacheJson(conn.id) == jsonEncode(encoded)) { if (_byConnection.containsKey(conn.id) && storage.getPlexHomeUsersCacheJson(conn.id) == jsonEncode(encoded)) {
appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}'); appLogger.d('PlexHomeService: home users unchanged for ${conn.accountLabel}');
return; return true;
} }
_byConnection[conn.id] = users; _byConnection[conn.id] = users;
await storage.savePlexHomeUsersCache(conn.id, encoded); await storage.savePlexHomeUsersCache(conn.id, encoded);
_emit(); _emit();
appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}'); appLogger.d('PlexHomeService: cached ${users.length} home users for ${conn.accountLabel}');
return true;
} catch (e, st) { } catch (e, st) {
appLogger.w('PlexHomeService: refresh failed for ${conn.accountLabel}', error: e, stackTrace: 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 '../exceptions/media_server_exceptions.dart';
import '../i18n/strings.g.dart';
import '../services/plex_auth_service.dart'; import '../services/plex_auth_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'profile_connection.dart';
import 'profile_connection_registry.dart';
/// Outcome of a Plex Home user switch attempt. /// Outcome of a Plex Home user switch attempt.
enum PlexHomeSwitchStatus { success, cancelled, failed } enum PlexHomeSwitchStatus { success, cancelled, failed }
@@ -51,7 +55,7 @@ Future<PlexHomeSwitchResult> switchPlexHomeUserWithPin({
return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken);
} on MediaServerHttpException catch (e) { } on MediaServerHttpException catch (e) {
if (e.statusCode == 403 && _isInvalidPin(e)) { if (e.statusCode == 403 && _isInvalidPin(e)) {
error = 'Incorrect PIN. Please try again.'; error = t.profiles.incorrectPinTryAgain;
pin = null; pin = null;
// Force the next iteration to prompt even when the caller didn't // Force the next iteration to prompt even when the caller didn't
// expect a PIN — Plex disagrees about whether one is required. // 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) { bool _isInvalidPin(MediaServerHttpException e) {
final data = e.responseData; final data = e.responseData;
if (data is! Map) return false; if (data is! Map) return false;
+57 -60
View File
@@ -5,19 +5,20 @@ import '../connection/connection.dart';
import '../connection/connection_registry.dart'; import '../connection/connection_registry.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../screens/profile/pin_entry_dialog.dart'; import '../screens/profile/pin_entry_dialog.dart';
import '../services/plex_auth_service.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import 'active_profile_binder.dart'; import 'active_profile_binder.dart';
import 'active_profile_provider.dart'; import 'active_profile_provider.dart';
import 'plex_home_switch.dart'; import 'plex_home_switch.dart';
import 'profile.dart'; import 'profile.dart';
import 'profile_connection.dart';
import 'profile_connection_registry.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 /// Activate [profile] from a UI surface, prompting for the PIN when the
/// profile is protected. Returns `true` on successful activation, `false` /// profile is protected. Loops on wrong-PIN entries until the user submits
/// when the user cancelled the PIN dialog. Loops on wrong-PIN entries /// the right PIN or backs out.
/// until the user submits the right PIN or backs out.
/// ///
/// The retry loop uses the same shake-on-error pattern as Plex Home users /// The retry loop uses the same shake-on-error pattern as Plex Home users
/// — see [showPinEntryDialog]. /// — see [showPinEntryDialog].
@@ -27,48 +28,60 @@ import 'profile_connection_registry.dart';
/// failed PIN never flips `_active`. The minted user-token is saved and /// 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 /// the profile is marked pre-verified on the binder, so it reuses the cached
/// token instead of re-prompting for the same PIN. /// 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 active = context.read<ActiveProfileProvider>();
final binder = context.read<ActiveProfileBinder>(); final binder = context.read<ActiveProfileBinder>();
if (profile.isPlexHome) { if (profile.isPlexHome) {
if (profile.plexProtected) { if (profile.plexProtected) {
final ok = await _preVerifyPlexHomePin(context, profile); final verified = await _preVerifyPlexHomePin(context, profile);
if (!ok) return false; if (verified != PlexHomeSwitchStatus.success) {
return verified == PlexHomeSwitchStatus.cancelled
? ProfileActivationOutcome.cancelled
: ProfileActivationOutcome.failed;
}
} }
binder.markUserInitiatedActivation(profile.id); binder.markUserInitiatedActivation(profile.id);
return active.activate(profile); return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed;
} }
if (!profile.isPinProtected) { if (!profile.isPinProtected) {
binder.markUserInitiatedActivation(profile.id); binder.markUserInitiatedActivation(profile.id);
return active.activate(profile); return await active.activate(profile) ? ProfileActivationOutcome.activated : ProfileActivationOutcome.failed;
} }
String? errorMessage; String? errorMessage;
while (true) { while (true) {
if (!context.mounted) return false; if (!context.mounted) return ProfileActivationOutcome.cancelled;
final pin = await showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); 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; final hash = profile.pinHash;
if (hash != null && verifyPin(pin, hash)) { if (hash != null && verifyPin(pin, hash)) {
binder.markUserInitiatedActivation(profile.id); 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 /// Activate [profile] from a UI surface, then wait until the active profile's
/// server/token binding has settled. Shows the standard switch failure message /// 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 { Future<bool> switchProfileFromUi(BuildContext context, Profile profile) async {
final activeProvider = context.read<ActiveProfileProvider>(); final activeProvider = context.read<ActiveProfileProvider>();
final ok = await activateProfileWithPin(context, profile); final outcome = await activateProfileWithPin(context, profile);
if (!context.mounted) return false; if (!context.mounted) return false;
if (!ok) { switch (outcome) {
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName)); case ProfileActivationOutcome.cancelled:
return false; return false;
case ProfileActivationOutcome.failed:
showErrorSnackBar(context, t.errors.failedToSwitchProfile(displayName: profile.displayName));
return false;
case ProfileActivationOutcome.activated:
break;
} }
final bound = await activeProvider.awaitBindingSettle(); 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 /// Validate [profile]'s PIN with Plex via `/home/users/{uuid}/switch`. On
/// success, persist the minted user-token and mark the profile as /// success, persist the minted user-token and mark the profile as
/// pre-verified so [ActiveProfileBinder] reuses the cached token instead /// pre-verified so [ActiveProfileBinder] reuses the cached token instead
/// of re-prompting. Returns `false` on cancel or a final failure (caller /// of re-prompting.
/// must abort activation in that case).
/// ///
/// Returns `true` without doing anything when the profile lacks the /// Returns [PlexHomeSwitchStatus.success] without doing anything when the
/// parent/uuid metadata or the parent connection is missing — the /// profile lacks the parent/uuid metadata or the parent connection is
/// binder's existing missing-metadata path will fire and silently /// missing — the binder's existing missing-metadata path will fire and
/// produce an empty bind, matching today's behavior. We don't want to /// silently produce an empty bind, matching today's behavior. We don't
/// fail activation outright for users in unusual data states. /// want to fail activation outright for users in unusual data states.
Future<bool> _preVerifyPlexHomePin(BuildContext context, Profile profile) async { Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile profile) async {
final parentId = profile.parentConnectionId; final parentId = profile.parentConnectionId;
final homeUuid = profile.plexHomeUserUuid; 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 connections = context.read<ConnectionRegistry>();
final pcRegistry = context.read<ProfileConnectionRegistry>();
final binder = context.read<ActiveProfileBinder>();
final all = await connections.list(); final all = await connections.list();
PlexAccountConnection? account; PlexAccountConnection? account;
for (final c in all) { for (final c in all) {
@@ -105,39 +119,22 @@ Future<bool> _preVerifyPlexHomePin(BuildContext context, Profile profile) async
break; break;
} }
} }
if (account == null) return true; if (account == null) return PlexHomeSwitchStatus.success;
final auth = await PlexAuthService.create(); final result = await mintPlexHomeUserToken(
try { account: account,
final result = await switchPlexHomeUserWithPin( homeUserUuid: homeUuid,
auth: auth, requiresPin: true,
accountToken: account.accountToken, promptForPin: ({String? errorMessage}) async {
homeUserUuid: homeUuid, if (!context.mounted) return null;
requiresPin: true, return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
promptForPin: ({String? errorMessage}) async { },
if (!context.mounted) return null; persistTo: pcRegistry,
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); persistProfileId: profile.id,
}, logLabel: profile.displayName,
logLabel: profile.displayName, );
); if (result.succeeded) binder.markPlexHomePreVerified(profile.id);
if (!result.succeeded) return false; return result.status;
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();
}
} }
/// Verify [pin] against [profile]'s stored PIN hash *without* activating it. /// 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 'package:provider/provider.dart';
import '../connection/connection.dart'; import '../connection/connection.dart';
import '../connection/connection_registry.dart'; import '../connection/connection_registry.dart';
import '../connection/plex_account_setup.dart';
import '../mixins/controller_disposer_mixin.dart'; import '../mixins/controller_disposer_mixin.dart';
import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_binder.dart';
import '../profiles/active_profile_provider.dart'; import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart'; import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart'; import '../profiles/profile.dart';
import '../profiles/profile_connection_registry.dart';
import '../services/plex_auth_service.dart'; import '../services/plex_auth_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
@@ -45,7 +47,9 @@ class _AuthScreenState extends State<AuthScreen> {
@override @override
void initState() { void initState() {
super.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 { Future<void> _initVerifyService() async {
@@ -96,44 +100,47 @@ class _AuthScreenState extends State<AuthScreen> {
}); });
final connectionRegistry = context.read<ConnectionRegistry>(); final connectionRegistry = context.read<ConnectionRegistry>();
final profileConnections = context.read<ProfileConnectionRegistry>();
final plexHome = context.read<PlexHomeService>(); final plexHome = context.read<PlexHomeService>();
final svc = await PlexAuthService.create();
try { 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 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) { if (accountConnection.servers.isEmpty) {
await storage.clearCredentials(); // 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; if (!mounted) return;
setState(() { setState(() {
_isAuthenticating = false; _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; 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; if (!mounted) return;
final activeProfiles = context.read<ActiveProfileProvider>(); final activeProfiles = context.read<ActiveProfileProvider>();
await _selectInitialProfile(plexHome, activeProfiles, accountConnection); await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
@@ -182,8 +189,6 @@ class _AuthScreenState extends State<AuthScreen> {
_isAuthenticating = false; _isAuthenticating = false;
_errorMessage = t.serverSelection.failedToLoadServers(error: e); _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/navigation_tabs.dart';
import '../navigation/profile_navigation_scope.dart'; import '../navigation/profile_navigation_scope.dart';
import '../profiles/active_profile_binder.dart'; import '../profiles/active_profile_binder.dart';
import '../connection/connection_registry.dart';
import '../profiles/active_profile_provider.dart'; import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart'; import '../profiles/plex_home_service.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
@@ -60,6 +61,7 @@ import 'search_screen.dart';
import 'downloads/downloads_screen.dart'; import 'downloads/downloads_screen.dart';
import 'settings/settings_screen.dart'; import 'settings/settings_screen.dart';
import 'profile/profile_switch_screen.dart'; import 'profile/profile_switch_screen.dart';
import 'profile/profile_teardown.dart';
import '../services/system_shelf_service.dart'; import '../services/system_shelf_service.dart';
import '../watch_together/watch_together.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 // we only invalidate on id change and the libraries sidebar keeps
// stale entries until the user switches profiles. // stale entries until the user switches profiles.
bool _wasBindingPrev = false; bool _wasBindingPrev = false;
bool _hadProfiles = false;
/// Subscription to MultiServerManager status changes. Used to resume any /// Subscription to MultiServerManager status changes. Used to resume any
/// queued downloads as soon as a Plex client comes online for the first /// queued downloads as soon as a Plex client comes online for the first
@@ -510,6 +513,16 @@ class _MainScreenState extends State<MainScreen>
_lastSeenProfileId = id; _lastSeenProfileId = id;
_wasBindingPrev = isBindingNow; _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 // Same active id, but a rebind cycle for that profile just settled
// (true → false transition). Fires after borrow / connection-removal // (true → false transition). Fires after borrow / connection-removal
// flows trigger ActiveProfileBinder.rebindIfActive, so the libraries // flows trigger ActiveProfileBinder.rebindIfActive, so the libraries
@@ -524,6 +537,7 @@ class _MainScreenState extends State<MainScreen>
if (widget.initialPromptHandled) return; if (widget.initialPromptHandled) return;
final activeProfile = context.read<ActiveProfileProvider>(); final activeProfile = context.read<ActiveProfileProvider>();
final connections = context.read<ConnectionRegistry>();
// The provider's initialize() is fire-and-forget from MultiProvider — // The provider's initialize() is fire-and-forget from MultiProvider —
// wait for it to settle so `active` and `profiles` reflect storage // wait for it to settle so `active` and `profiles` reflect storage
// before we decide whether to prompt. // before we decide whether to prompt.
@@ -533,6 +547,20 @@ class _MainScreenState extends State<MainScreen>
final settingsService = await SettingsService.getInstance(); final settingsService = await SettingsService.getInstance();
if (!mounted) return; 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 // Always prompt when there's no active profile but profiles exist
// (fresh sign-in with multiple Plex Home users): otherwise the binder // (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 // 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); void _clearPin() => setState(() => _pinHash = null);
Future<void> _saveAndContinue() async { Future<void> _saveAndContinue() async {
if (_saving) return;
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isEmpty) return; if (name.isEmpty) return;
setState(() => _saving = true); setState(() => _saving = true);
final registry = context.read<ProfileRegistry>(); try {
final profile = Profile.local( final registry = context.read<ProfileRegistry>();
id: 'local-${const Uuid().v4()}', final profile = Profile.local(
displayName: name, id: 'local-${const Uuid().v4()}',
pinHash: _pinHash, displayName: name,
sortOrder: DateTime.now().millisecondsSinceEpoch, pinHash: _pinHash,
createdAt: DateTime.now(), sortOrder: DateTime.now().millisecondsSinceEpoch,
); createdAt: DateTime.now(),
await registry.upsert(profile); );
await registry.upsert(profile);
if (!mounted) return; if (!mounted) return;
// Drop the user into the connection picker so they end up with at least // 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 // one connection. The picker offers both new sign-ins and borrowing from
// existing profiles — empty borrow lists no longer trap the user. // existing profiles — empty borrow lists no longer trap the user.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile))); await navigator.push(MaterialPageRoute(builder: (_) => AddConnectionScreen(targetProfile: profile)));
if (!mounted) return; if (!mounted) return;
navigator.pop(true); 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 @override
@@ -17,7 +17,6 @@ import '../../profiles/profile_connection.dart';
import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_connection_registry.dart';
import '../../profiles/profile_merge.dart'; import '../../profiles/profile_merge.dart';
import '../../profiles/profile_registry.dart'; import '../../profiles/profile_registry.dart';
import '../../services/plex_auth_service.dart';
import '../../services/storage_service.dart'; import '../../services/storage_service.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
@@ -214,6 +213,13 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
case JellyfinConnection(): case JellyfinConnection():
await _borrowJellyfin(cand); 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 { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -257,77 +263,53 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount); if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount);
return false; return false;
} }
final auth = await PlexAuthService.create(); final result = await mintPlexHomeUserToken(
try { account: parent,
final result = await switchPlexHomeUserWithPin( homeUserUuid: homeUuid,
auth: auth, requiresPin: true,
accountToken: parent.accountToken, promptForPin: ({String? errorMessage}) async {
homeUserUuid: homeUuid, if (!mounted) return null;
requiresPin: true, return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
promptForPin: ({String? errorMessage}) async { },
if (!mounted) return null; logLabel: cand.source.displayName,
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); );
}, if (!result.succeeded) {
logLabel: cand.source.displayName, if (result.status == PlexHomeSwitchStatus.failed && mounted) {
); showErrorSnackBar(context, t.profiles.failedToVerifyPin);
if (!result.succeeded) {
if (result.status == PlexHomeSwitchStatus.failed && mounted) {
showErrorSnackBar(context, t.profiles.failedToVerifyPin);
}
return false;
} }
return true; return false;
} finally {
auth.dispose();
} }
return true;
} }
Future<void> _borrowPlex(_BorrowCandidate cand) async { Future<void> _borrowPlex(_BorrowCandidate cand) async {
final pcRegistry = context.read<ProfileConnectionRegistry>(); final pcRegistry = context.read<ProfileConnectionRegistry>();
final auth = await PlexAuthService.create(); final account = cand.connection as PlexAccountConnection;
try { final result = await mintPlexHomeUserToken(
final account = cand.connection as PlexAccountConnection; account: account,
final result = await switchPlexHomeUserWithPin( homeUserUuid: cand.pc.userIdentifier,
auth: auth, requiresPin: cand.source.plexProtected,
accountToken: account.accountToken, promptForPin: ({String? errorMessage}) async {
homeUserUuid: cand.pc.userIdentifier, if (!mounted) return null;
requiresPin: cand.source.plexProtected, return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
promptForPin: ({String? errorMessage}) async { },
if (!mounted) return null; persistTo: pcRegistry,
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); persistProfileId: widget.targetProfile.id,
}, logLabel: cand.source.displayName,
logLabel: cand.source.displayName, );
); if (!result.succeeded) {
if (!result.succeeded) { if (result.status == PlexHomeSwitchStatus.failed && mounted) {
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) {
showErrorSnackBar(context, t.profiles.borrowFailed); showErrorSnackBar(context, t.profiles.borrowFailed);
} }
} finally { return;
auth.dispose(); }
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 onChange;
final VoidCallback onRemove; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -35,6 +39,7 @@ class PinStatusRow extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
FocusableButton( FocusableButton(
focusNode: changeFocusNode,
onPressed: onChange, onPressed: onChange,
child: TextButton(onPressed: onChange, child: Text(t.profiles.changePin)), 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 _nameFocusNode = FocusNode(debugLabel: 'ProfileDetail:Name');
final _saveNameFocusNode = FocusNode(debugLabel: 'ProfileDetail:SaveName'); final _saveNameFocusNode = FocusNode(debugLabel: 'ProfileDetail:SaveName');
final _setPinFocusNode = FocusNode(debugLabel: 'ProfileDetail:SetPin'); final _setPinFocusNode = FocusNode(debugLabel: 'ProfileDetail:SetPin');
final _changePinFocusNode = FocusNode(debugLabel: 'ProfileDetail:ChangePin');
final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection'); final _addConnectionFocusNode = FocusNode(debugLabel: 'ProfileDetail:AddConnection');
final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile'); final _deleteProfileFocusNode = FocusNode(debugLabel: 'ProfileDetail:DeleteProfile');
late Profile _profile; late Profile _profile;
StreamSubscription<List<Profile>>? _profileSub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_profile = widget.profile; _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 @override
void dispose() { void dispose() {
_profileSub?.cancel();
_nameFocusNode.dispose(); _nameFocusNode.dispose();
_saveNameFocusNode.dispose(); _saveNameFocusNode.dispose();
_setPinFocusNode.dispose(); _setPinFocusNode.dispose();
_changePinFocusNode.dispose();
_addConnectionFocusNode.dispose(); _addConnectionFocusNode.dispose();
_deleteProfileFocusNode.dispose(); _deleteProfileFocusNode.dispose();
super.dispose(); super.dispose();
@@ -95,10 +119,18 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
if (pin == null || !mounted) return; if (pin == null || !mounted) return;
final profile = _profile; final profile = _profile;
if (profile is! LocalProfile) return; if (profile is! LocalProfile) return;
final hadPin = profile.pinHash != null;
final updated = profile.copyWith(pinHash: computePinHash(pin)); final updated = profile.copyWith(pinHash: computePinHash(pin));
await context.read<ProfileRegistry>().upsert(updated); await context.read<ProfileRegistry>().upsert(updated);
if (!mounted) return; if (!mounted) return;
setState(() => _profile = updated); 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 { Future<void> _clearPin() async {
@@ -108,6 +140,11 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
await context.read<ProfileRegistry>().upsert(updated); await context.read<ProfileRegistry>().upsert(updated);
if (!mounted) return; if (!mounted) return;
setState(() => _profile = updated); 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 { Future<void> _addConnection() async {
@@ -126,23 +163,60 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
isDestructive: true, isDestructive: true,
); );
if (!confirmed || !mounted) return; 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, _profile.id,
_serverIdsForConnection(conn), _serverIdsForConnection(conn).difference(retainedServerIds),
); );
if (!mounted) return;
await removeProfileConnectionAndCleanup( await removeProfileConnectionAndCleanup(
profileId: _profile.id, profileId: _profile.id,
connection: conn, connection: conn,
profileConnections: context.read<ProfileConnectionRegistry>(), profileConnections: pcRegistry,
connections: context.read<ConnectionRegistry>(), connections: connRegistry,
storage: context.read<StorageService>(), storage: storage,
serverManager: context.read<MultiServerProvider>().serverManager, serverManager: serverManager,
); );
if (!mounted) return; await hiddenLibraries?.refresh();
await context.read<HiddenLibrariesProvider?>()?.refresh(); unawaited(binder.rebindIfActive(_profile.id));
if (!mounted) return; }
unawaited(context.read<ActiveProfileBinder>().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 { Future<void> _editConnection(Connection conn) async {
@@ -245,7 +319,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
), ),
) )
else else
PinStatusRow(onChange: _setPin, onRemove: _clearPin), PinStatusRow(onChange: _setPin, onRemove: _clearPin, changeFocusNode: _changePinFocusNode),
const SizedBox(height: 32), const SizedBox(height: 32),
Row( Row(
children: [ children: [
@@ -287,7 +361,7 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
} }
} }
class _ConnectionsList extends StatelessWidget { class _ConnectionsList extends StatefulWidget {
final Profile profile; final Profile profile;
final Future<void> Function(ProfileConnection pc, Connection conn) onRemove; final Future<void> Function(ProfileConnection pc, Connection conn) onRemove;
final Future<void> Function(Connection conn) onEdit; final Future<void> Function(Connection conn) onEdit;
@@ -300,15 +374,37 @@ class _ConnectionsList extends StatelessWidget {
required this.onSignOutParent, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final profile = widget.profile;
final pcRegistry = context.read<ProfileConnectionRegistry>(); final pcRegistry = context.read<ProfileConnectionRegistry>();
final connRegistry = context.read<ConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
return StreamBuilder<List<ProfileConnection>>( return StreamBuilder<List<ProfileConnection>>(
stream: pcRegistry.watchForProfile(profile.id), stream: _pcsStream,
builder: (context, snapshot) { builder: (context, snapshot) {
final pcs = snapshot.data ?? const <ProfileConnection>[]; final pcs = snapshot.data ?? const <ProfileConnection>[];
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
@@ -318,12 +414,12 @@ class _ConnectionsList extends StatelessWidget {
); );
} }
return StreamBuilder<Map<String, List<PlexHomeUser>>>( return StreamBuilder<Map<String, List<PlexHomeUser>>>(
stream: plexHome.stream, stream: _homeStream,
initialData: plexHome.current, initialData: _homeInitial,
builder: (context, homeSnap) { builder: (context, homeSnap) {
final homeCache = homeSnap.data ?? const <String, List<PlexHomeUser>>{}; final homeCache = homeSnap.data ?? const <String, List<PlexHomeUser>>{};
return FutureBuilder<List<Connection>>( return StreamBuilder<List<Connection>>(
future: connRegistry.list(), stream: _connectionsStream,
builder: (context, snap) { builder: (context, snap) {
final all = snap.data ?? const <Connection>[]; final all = snap.data ?? const <Connection>[];
final byId = {for (final c in all) c.id: c}; final byId = {for (final c in all) c.id: c};
@@ -356,7 +452,7 @@ class _ConnectionsList extends StatelessWidget {
tooltip: t.profiles.manage, tooltip: t.profiles.manage,
onSelected: (value) { onSelected: (value) {
if (value == 'sign_out') { if (value == 'sign_out') {
unawaited(onSignOutParent(parentConn)); unawaited(widget.onSignOutParent(parentConn));
} }
}, },
itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)], itemBuilder: (_) => [AppMenuItem(value: 'sign_out', label: t.profiles.signOut)],
@@ -377,9 +473,9 @@ class _ConnectionsList extends StatelessWidget {
if (value == 'default') { if (value == 'default') {
unawaited(pcRegistry.setDefault(profile.id, pc.connectionId)); unawaited(pcRegistry.setDefault(profile.id, pc.connectionId));
} else if (value == 'edit') { } else if (value == 'edit') {
unawaited(onEdit(conn)); unawaited(widget.onEdit(conn));
} else if (value == 'remove') { } else if (value == 'remove') {
unawaited(onRemove(pc, conn)); unawaited(widget.onRemove(pc, conn));
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
+45 -12
View File
@@ -106,8 +106,11 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
}, },
child: StreamBuilder<ProfilesView>( child: StreamBuilder<ProfilesView>(
stream: _viewStream, stream: _viewStream,
initialData: ProfilesView.empty,
builder: (context, snapshot) { 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; final view = snapshot.data ?? ProfilesView.empty;
_pruneProfileFocusResources(view.profiles.map((p) => p.id).toSet()); _pruneProfileFocusResources(view.profiles.map((p) => p.id).toSet());
// `context.select` only rebuilds when `activeId` actually // `context.select` only rebuilds when `activeId` actually
@@ -124,11 +127,13 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
slivers: [ slivers: [
if (view.profiles.isEmpty) if (view.profiles.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: EmptyStateWidget( child: loading
message: t.messages.noProfilesAvailable, ? const Center(child: CircularProgressIndicator())
subtitle: t.messages.contactAdminForProfiles, : EmptyStateWidget(
icon: Symbols.person_off_rounded, message: t.messages.noProfilesAvailable,
), subtitle: t.messages.contactAdminForProfiles,
icon: Symbols.person_off_rounded,
),
) )
else else
..._buildSections(view, activeId), ..._buildSections(view, activeId),
@@ -172,14 +177,23 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
} }
void _pruneProfileFocusResources(Set<String> activeIds) { 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()) { for (final id in _profileFocusNodes.keys.toList()) {
if (!activeIds.contains(id)) { 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()) { for (final id in _profileMenuFocusNodes.keys.toList()) {
if (!activeIds.contains(id)) { 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()) { for (final id in _profileMenuKeys.keys.toList()) {
@@ -187,6 +201,13 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
_profileMenuKeys.remove(id); _profileMenuKeys.remove(id);
} }
} }
if (removed.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final node in removed) {
node.dispose();
}
});
}
} }
void _openProfileMenu(Profile profile) { void _openProfileMenu(Profile profile) {
@@ -206,9 +227,14 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
final profileFocusNode = _profileFocusNode(profile); final profileFocusNode = _profileFocusNode(profile);
final menuFocusNode = _profileMenuFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile);
final menuKey = _profileMenuKey(profile); final menuKey = _profileMenuKey(profile);
final onManage = !widget.requireSelection ? () => _manageProfile(profile) : null; // All tile actions are disabled while a switch is binding: the
final onDelete = profile.isLocal && !widget.requireSelection ? () => _deleteProfile(profile) : null; // overlay's barrier blocks pointers but not DPAD key events, and a
final onSignOut = profile.isPlexHome && profile.parentConnectionId != null && !widget.requireSelection // 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) ? () => _signOutPlexAccount(profile)
: null; : null;
final hasMenu = onManage != null || onDelete != null || onSignOut != null; final hasMenu = onManage != null || onDelete != null || onSignOut != null;
@@ -307,13 +333,20 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
if (_switching) return; if (_switching) return;
setState(() => _switching = true); setState(() => _switching = true);
try { try {
final route = ModalRoute.of(context);
final navigator = Navigator.of(context, rootNavigator: true); final navigator = Navigator.of(context, rootNavigator: true);
final switched = await switchProfileFromUi(context, profile); final switched = await switchProfileFromUi(context, profile);
if (!mounted || !switched) return; if (!mounted || !switched) return;
if (widget.requireSelection) { if (widget.requireSelection) {
setState(() => _allowPop = true); 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 { } finally {
setStateIfMounted(() => _switching = false); setStateIfMounted(() => _switching = false);
} }
@@ -7,14 +7,16 @@ import 'package:provider/provider.dart';
import '../../connection/connection.dart'; import '../../connection/connection.dart';
import '../../connection/connection_registry.dart'; import '../../connection/connection_registry.dart';
import '../../connection/plex_account_setup.dart';
import '../../focus/focusable_button.dart'; import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart'; import '../../profiles/plex_home_service.dart';
import '../../profiles/profile.dart'; import '../../profiles/profile.dart';
import '../../profiles/profile_connection_cleanup.dart';
import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_connection_registry.dart';
import '../../services/plex_auth_service.dart'; import '../../services/storage_service.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../media/media_backend.dart'; import '../../media/media_backend.dart';
import '../../widgets/backend_badge.dart'; import '../../widgets/backend_badge.dart';
@@ -22,7 +24,6 @@ import '../../widgets/focused_scroll_scaffold.dart';
import '../auth/plex_pin_auth_flow.dart'; import '../auth/plex_pin_auth_flow.dart';
import '../profile/borrow_connection_screen.dart'; import '../profile/borrow_connection_screen.dart';
import 'async_form_state_mixin.dart'; import 'async_form_state_mixin.dart';
import 'connection_persistence.dart';
/// Add a Plex account to the [ConnectionRegistry]. /// Add a Plex account to the [ConnectionRegistry].
/// ///
@@ -52,83 +53,56 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
Future<void> _onTokenReceived(String token) async { Future<void> _onTokenReceived(String token) async {
final completed = await runAsync<bool>( final completed = await runAsync<bool>(
() async { () async {
// Pull the account label first so the row is human-readable. Falls final connRegistry = context.read<ConnectionRegistry>();
// back to "Plex" when the user info call fails (rare; e.g. token final pcRegistry = context.read<ProfileConnectionRegistry>();
// works but plex.tv is rate-limiting). final plexHome = context.read<PlexHomeService>();
String accountLabel = 'Plex'; final storage = context.read<StorageService>();
// Account UUID from plex.tv — this is what makes multi-account work. final target = widget.targetProfile;
// 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 servers = await auth.fetchServers(token); // Shared token→connection pipeline: identity resolution, dedup of a
if (!mounted) return false; // 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( if (!mounted) return false;
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}', if (target != null) {
accountToken: token, final borrowed = await Navigator.of(context).push<bool>(
clientIdentifier: auth.clientIdentifier, MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)),
accountLabel: accountLabel,
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
); );
if (borrowed != true) {
if (!mounted) return false; // The user backed out of picking a home user — a cancel, not an
// Persist the registry row. Binding is deliberately left to // error. If this flow created the account solely to attach it
// ActiveProfileBinder below (global reauth) or the borrow flow // to the profile, remove it again so a cancelled attach doesn't
// (profile-scoped add) so we never put the raw account token into // leave a global account behind.
// the active runtime session. if (!registration.existedBefore) {
final target = widget.targetProfile; await removePlexAccountConnectionAndCleanup(
await persistAndBindConnection( account: connection,
context: context, profileConnections: pcRegistry,
connection: connection, connections: connRegistry,
bindToProfile: null, storage: storage,
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;
} }
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) Navigator.of(context).pop(true);
if (!mounted) return false;
Navigator.of(context).pop(true);
return true; return true;
} finally {
auth.dispose();
} }
await _rebindActiveIfUses(connection.id);
if (!mounted) return false;
Navigator.of(context).pop(true);
return true;
}, },
errorMapper: (e) { errorMapper: (e) {
appLogger.e('Failed to register Plex account', error: e); appLogger.e('Failed to register Plex account', error: e);
@@ -34,11 +34,16 @@ Future<bool> persistAndBindConnection({
required Future<bool> Function()? addToManager, required Future<bool> Function()? addToManager,
String? visibleServerId, String? visibleServerId,
}) async { }) 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) { if (bindToProfile != null) {
await context.read<ProfileConnectionRegistry>().upsert(bindToProfile); await profileConnections.upsert(bindToProfile);
} }
if (!context.mounted || addToManager == null) return false; 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 '../../widgets/settings_section.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart'; import '../../profiles/profile.dart';
import '../../profiles/profile_registry.dart';
import 'about_screen.dart'; import 'about_screen.dart';
import 'add_connection_screen.dart'; import 'add_connection_screen.dart';
import 'appearance_settings_screen.dart'; import 'appearance_settings_screen.dart';
@@ -276,29 +275,26 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
} }
Widget _buildProfilesSection() { Widget _buildProfilesSection() {
return StreamBuilder<List<Profile>>( // ActiveProfileProvider already merges local rows with virtual Plex
stream: context.read<ProfileRegistry>().watchProfiles(), // Home profiles — counting only the local DB rows made every Plex Home
builder: (context, snapshot) { // household read as a single profile here. `context.select` keeps
final count = snapshot.data?.length ?? 0; // rebuilds scoped to actual count/name changes (a StreamBuilder here
// `context.select` so this StreamBuilder doesn't rebuild on every // was also re-created on every settings rebuild).
// ActiveProfileProvider notification — only when the active final count = context.select<ActiveProfileProvider, int>((p) => p.profiles.length);
// profile's display name actually changes. final activeName = context.select<ActiveProfileProvider, String?>((p) => p.active?.displayName);
final activeName = context.select<ActiveProfileProvider, String?>((p) => p.active?.displayName); final subtitle = count <= 1
final subtitle = count <= 1 ? t.profiles.summarySingle
? t.profiles.summarySingle : (activeName != null
: (activeName != null ? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName)
? t.profiles.summaryMultipleWithActive(count: count, activeName: activeName) : t.profiles.summaryMultiple(count: count));
: t.profiles.summaryMultiple(count: count)); return SettingNavigationTile(
return SettingNavigationTile( icon: Symbols.group_rounded,
icon: Symbols.group_rounded, title: t.profiles.sectionTitle,
title: t.profiles.sectionTitle, subtitle: subtitle,
subtitle: subtitle, onTap: () => Navigator.of(
onTap: () => Navigator.of( context,
context, rootNavigator: true,
rootNavigator: true, ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())),
).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen())),
);
},
); );
} }
@@ -36,7 +36,7 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(true); TvDetectionService.debugSetAppleTVOverride(true);
final db = AppDatabase.forTesting(NativeDatabase.memory()); final db = AppDatabase.forTesting(NativeDatabase.memory());
final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); 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 connections = _FakeConnectionRegistry(db);
final profileConnections = _FakeProfileConnectionRegistry(db); final profileConnections = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance(); final storage = await StorageService.getInstance();
@@ -106,6 +106,20 @@ class _FakeConnectionRegistry extends ConnectionRegistry {
@override @override
Future<List<Connection>> list() async => const []; 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 { class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {