feat: jellyfin
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M24.2116 49.1581C22.6599 46.0424 32.8378 27.5879 35.9999 27.5879C39.1666 27.5895 49.3228 46.0764 47.7882 49.1581C46.2536 52.2398 25.7632 52.2738 24.2116 49.1581Z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.481861 64.9951C-4.19479 55.6047 26.4765 0 36 0C45.5328 0 76.153 55.713 71.5274 64.9951C66.9018 74.2773 5.15852 74.3856 0.481861 64.9951ZM12.7358 56.847C15.8005 62.9995 56.2536 62.9314 59.2843 56.847C62.3149 50.761 42.2515 14.2605 36.0093 14.2605C29.767 14.2605 9.67118 50.6944 12.7358 56.847Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="636.71 89.58 269.72 269.72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M774.47 359.3H677.74L768.67 224.5L677.74 89.58H774.47L865.4 224.5L774.47 359.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 199 B |
@@ -0,0 +1,328 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
|
||||
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
|
||||
/// [MediaBackend] for places that only care about persistence/auth shape
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
};
|
||||
}
|
||||
|
||||
/// Health snapshot for a connection. Updated by the orchestrator each time a
|
||||
/// session is established or refreshed.
|
||||
enum ConnectionStatus { unknown, online, offline, authError, disabled }
|
||||
|
||||
/// A media server connection — a unit of authentication the user added.
|
||||
///
|
||||
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
|
||||
/// optional active Home profile. A `JellyfinConnection` is a single server +
|
||||
/// user. Most users only ever add one connection.
|
||||
sealed class Connection {
|
||||
String get id;
|
||||
ConnectionKind get kind;
|
||||
String get displayName;
|
||||
ConnectionStatus get status;
|
||||
DateTime get createdAt;
|
||||
DateTime? get lastAuthenticatedAt;
|
||||
|
||||
/// Backend kind as a [MediaBackend] — for UI that branches on backend
|
||||
/// (badges, etc.). Just a passthrough to [kind.backend].
|
||||
MediaBackend get backend => kind.backend;
|
||||
|
||||
/// Primary label shown in connection-list UIs. Plex shows the active
|
||||
/// profile/account name; Jellyfin shows the server name.
|
||||
String get displayLabel;
|
||||
|
||||
/// Secondary line shown beneath [displayLabel] in connection-list UIs.
|
||||
/// Plex: server count; Jellyfin: `userName · baseUrl`. May be null when
|
||||
/// no useful subtitle exists.
|
||||
String? get displaySubtitle;
|
||||
|
||||
/// Backend-specific config payload, persisted as JSON. Each subclass
|
||||
/// defines the schema.
|
||||
Map<String, Object?> toConfigJson();
|
||||
}
|
||||
|
||||
/// A Plex account connection.
|
||||
///
|
||||
/// Fields here mirror what [PlexAuthService] gathers during PIN OAuth: an
|
||||
/// account token (long-lived), the per-device client identifier (so plex.tv
|
||||
/// doesn't see a "new device" each launch), and the optional Home user the
|
||||
/// user has switched into.
|
||||
class PlexAccountConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// plex.tv account access token.
|
||||
final String accountToken;
|
||||
|
||||
/// Per-device client identifier. Stable across launches.
|
||||
final String clientIdentifier;
|
||||
|
||||
/// Display name shown for this connection (typically the Plex account email
|
||||
/// or username, fallback "Plex").
|
||||
final String accountLabel;
|
||||
|
||||
/// Active Home user, or `null` for the main account.
|
||||
final PlexHomeUser? activeProfile;
|
||||
|
||||
/// Servers discovered for this account (cached). Populated by the auth
|
||||
/// flow and refreshed periodically.
|
||||
final List<PlexServer> servers;
|
||||
|
||||
PlexAccountConnection({
|
||||
required this.id,
|
||||
required this.accountToken,
|
||||
required this.clientIdentifier,
|
||||
required this.accountLabel,
|
||||
this.activeProfile,
|
||||
this.servers = const [],
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.plex;
|
||||
|
||||
@override
|
||||
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
|
||||
? '${activeProfile!.title} · $accountLabel'
|
||||
: accountLabel;
|
||||
|
||||
@override
|
||||
String get displayLabel => displayName;
|
||||
|
||||
@override
|
||||
String? get displaySubtitle => servers.length == 1 ? '1 Plex server' : '${servers.length} Plex servers';
|
||||
|
||||
PlexAccountConnection copyWith({
|
||||
String? id,
|
||||
String? accountToken,
|
||||
String? clientIdentifier,
|
||||
String? accountLabel,
|
||||
PlexHomeUser? activeProfile,
|
||||
bool clearActiveProfile = false,
|
||||
List<PlexServer>? servers,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
return PlexAccountConnection(
|
||||
id: id ?? this.id,
|
||||
accountToken: accountToken ?? this.accountToken,
|
||||
clientIdentifier: clientIdentifier ?? this.clientIdentifier,
|
||||
accountLabel: accountLabel ?? this.accountLabel,
|
||||
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
|
||||
servers: servers ?? this.servers,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
'accountToken': accountToken,
|
||||
'clientIdentifier': clientIdentifier,
|
||||
'accountLabel': accountLabel,
|
||||
'activeProfile': activeProfile?.toJson(),
|
||||
'servers': servers.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory PlexAccountConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
final profileJson = json['activeProfile'];
|
||||
final activeProfile = profileJson is Map<String, dynamic> ? PlexHomeUser.fromJson(profileJson) : null;
|
||||
final serversJson = json['servers'];
|
||||
final servers = serversJson is List
|
||||
? serversJson.whereType<Map<String, dynamic>>().map(PlexServer.fromJson).toList()
|
||||
: <PlexServer>[];
|
||||
return PlexAccountConnection(
|
||||
id: id,
|
||||
accountToken: json['accountToken'] as String? ?? '',
|
||||
clientIdentifier: json['clientIdentifier'] as String? ?? '',
|
||||
accountLabel: json['accountLabel'] as String? ?? 'Plex',
|
||||
activeProfile: activeProfile,
|
||||
servers: servers,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-server Jellyfin connection.
|
||||
class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// Server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
|
||||
final String baseUrl;
|
||||
|
||||
/// Server's reported name (System/Info).
|
||||
final String serverName;
|
||||
|
||||
/// Server's machine identifier (System/Info `Id`).
|
||||
final String serverMachineId;
|
||||
|
||||
/// Authenticated Jellyfin user id (UUID).
|
||||
final String userId;
|
||||
|
||||
/// Authenticated user's display name.
|
||||
final String userName;
|
||||
|
||||
/// Long-lived access token from `/Users/AuthenticateByName`.
|
||||
final String accessToken;
|
||||
|
||||
/// Per-device client identifier (same value sent in the
|
||||
/// `Authorization: MediaBrowser DeviceId="..."` header).
|
||||
final String deviceId;
|
||||
|
||||
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Captured at auth time so the UI can gate admin-only entries (delete,
|
||||
/// match/unmatch, edit metadata) without an extra round-trip.
|
||||
final bool isAdministrator;
|
||||
|
||||
JellyfinConnection({
|
||||
required this.id,
|
||||
required this.baseUrl,
|
||||
required this.serverName,
|
||||
required this.serverMachineId,
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.isAdministrator = false,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.jellyfin;
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
|
||||
@override
|
||||
String get displayLabel => serverName;
|
||||
|
||||
@override
|
||||
String? get displaySubtitle => '$userName · ${_truncateUrl(baseUrl)}';
|
||||
|
||||
static String _truncateUrl(String url) {
|
||||
if (url.length <= 40) return url;
|
||||
return '${url.substring(0, 37)}…';
|
||||
}
|
||||
|
||||
JellyfinConnection copyWith({
|
||||
String? id,
|
||||
String? baseUrl,
|
||||
String? serverName,
|
||||
String? serverMachineId,
|
||||
String? userId,
|
||||
String? userName,
|
||||
String? accessToken,
|
||||
String? deviceId,
|
||||
bool? isAdministrator,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
return JellyfinConnection(
|
||||
id: id ?? this.id,
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
serverName: serverName ?? this.serverName,
|
||||
serverMachineId: serverMachineId ?? this.serverMachineId,
|
||||
userId: userId ?? this.userId,
|
||||
userName: userName ?? this.userName,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
'baseUrl': baseUrl,
|
||||
'serverName': serverName,
|
||||
'serverMachineId': serverMachineId,
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'accessToken': accessToken,
|
||||
'deviceId': deviceId,
|
||||
'isAdministrator': isAdministrator,
|
||||
};
|
||||
}
|
||||
|
||||
factory JellyfinConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
return JellyfinConnection(
|
||||
id: id,
|
||||
baseUrl: json['baseUrl'] as String? ?? '',
|
||||
serverName: json['serverName'] as String? ?? 'Jellyfin',
|
||||
serverMachineId: json['serverMachineId'] as String? ?? '',
|
||||
userId: json['userId'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
accessToken: json['accessToken'] as String? ?? '',
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
isAdministrator: json['isAdministrator'] as bool? ?? false,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'connection.dart';
|
||||
|
||||
/// Backend-neutral auth service interface. Each backend's implementation
|
||||
/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its
|
||||
/// own UX (PIN flow vs. password) but produces the same opaque
|
||||
/// [Connection] record at the end.
|
||||
abstract class ConnectionAuthService {
|
||||
/// Best-effort check that an existing token still works. Returns false on
|
||||
/// 401/403; throws on transport failures the caller should retry.
|
||||
Future<bool> validate(Connection connection);
|
||||
|
||||
/// Refresh whatever side-channel state belongs to a connection — for Plex
|
||||
/// that's the discovered server list and Home users; for Jellyfin it's a
|
||||
/// no-op once auth has succeeded. Returns the updated connection.
|
||||
Future<Connection> refresh(Connection connection);
|
||||
|
||||
/// Revoke the token server-side and forget local credentials. The caller
|
||||
/// is responsible for removing the row from [ConnectionRegistry].
|
||||
Future<void> signOut(Connection connection);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/plex/plex_home.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/server_registry.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
import 'connection_registry.dart';
|
||||
|
||||
/// One-shot helpers that bridge between the legacy single-Plex-account
|
||||
/// SharedPreferences state (`StorageService.plexToken` +
|
||||
/// `currentUserUUID` + `homeUsersCache` + `ServerRegistry.getServers()`)
|
||||
/// and the new [ConnectionRegistry] world.
|
||||
///
|
||||
/// Plex Home users are NOT persisted here — the bootstrap copies the
|
||||
/// legacy `homeUsersCache` into the per-connection
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot so
|
||||
/// [PlexHomeService] picks it up on cold start.
|
||||
class ConnectionBootstrap {
|
||||
ConnectionBootstrap({
|
||||
required this.storage,
|
||||
required this.connectionRegistry,
|
||||
required this.serverRegistry,
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
final StorageService storage;
|
||||
final ConnectionRegistry connectionRegistry;
|
||||
final ServerRegistry serverRegistry;
|
||||
final ProfileRegistry profileRegistry;
|
||||
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
|
||||
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
|
||||
|
||||
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
|
||||
static const String _ownerProfileIdPrefix = 'local-';
|
||||
|
||||
/// Run all idempotent boot-time migrations. Best-effort — errors are
|
||||
/// logged but never thrown.
|
||||
Future<void> run() async {
|
||||
await seedFromDevTokenDefine();
|
||||
final hadLegacyPlexToken = (storage.getPlexToken() ?? '').isNotEmpty;
|
||||
final hadLegacyProfileState = _hasLegacyProfileState();
|
||||
final migratedAccount = await migrateLegacyPlexAccount();
|
||||
final account = await _firstPlexAccount(migratedAccount);
|
||||
final alreadyMigrated = storage.prefs.getBool(_keyProfileMigrationV1Done) ?? false;
|
||||
if (!alreadyMigrated) {
|
||||
if (account == null && (hadLegacyPlexToken || hadLegacyProfileState)) {
|
||||
appLogger.w('Migration: legacy profile state present but Plex account was not migrated; will retry later');
|
||||
return;
|
||||
}
|
||||
// Drop any plex_home rows left over from the pre-refactor data
|
||||
// model — Plex Home users are now fetched live, never persisted.
|
||||
await profileRegistry.dropAllPlexHomeRows();
|
||||
if (account != null) {
|
||||
final prepared = await _preparePlexVirtualProfile(account);
|
||||
if (!prepared) {
|
||||
if (migratedAccount != null && hadLegacyPlexToken) {
|
||||
await connectionRegistry.remove(migratedAccount.id);
|
||||
}
|
||||
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _ensureOwnerProfile();
|
||||
if (hadLegacyPlexToken) {
|
||||
await storage.clearLegacyPlexToken();
|
||||
}
|
||||
await storage.clearServersList();
|
||||
await storage.prefs.setBool(_keyProfileMigrationV1Done, true);
|
||||
} else {
|
||||
await storage.clearServersList();
|
||||
await _recoverLegacyProfilePromotionIfNeeded(account);
|
||||
await _migrateLegacyPlexHomeUsersCacheForExistingAccount(account);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasLegacyProfileState() {
|
||||
return (storage.getCurrentUserUUID() ?? '').isNotEmpty ||
|
||||
(storage.prefs.getString('home_users_cache') ?? '').isNotEmpty;
|
||||
}
|
||||
|
||||
/// Screenshot automation injects a Plex token via the `PLEX_TOKEN`
|
||||
/// dart-define so the app boots already-signed-in. Inserts a
|
||||
/// [PlexAccountConnection] directly when the env var is non-empty AND
|
||||
/// the registry doesn't already have a Plex account, fetching the user
|
||||
/// info + servers like the auth screen does. No-op in normal builds.
|
||||
Future<void> seedFromDevTokenDefine() async {
|
||||
const devToken = String.fromEnvironment('PLEX_TOKEN');
|
||||
if (devToken.isEmpty) return;
|
||||
final existing = await connectionRegistry.list();
|
||||
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
|
||||
|
||||
try {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final info = await auth.getUserInfo(devToken);
|
||||
final servers = await auth.fetchServers(devToken);
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.$clientId',
|
||||
accountToken: devToken,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
/// If a legacy Plex token sits in [StorageService] without a corresponding
|
||||
/// row in [ConnectionRegistry], wrap it as a [PlexAccountConnection] and
|
||||
/// insert. Best-effort: errors are logged but never thrown.
|
||||
Future<PlexAccountConnection?> migrateLegacyPlexAccount() async {
|
||||
final token = storage.getPlexToken();
|
||||
if (token == null || token.isEmpty) return null;
|
||||
|
||||
final existing = await connectionRegistry.list();
|
||||
final alreadyMigrated = existing
|
||||
.whereType<PlexAccountConnection>()
|
||||
.where((c) => c.accountToken == token)
|
||||
.firstOrNull;
|
||||
if (alreadyMigrated != null) {
|
||||
return alreadyMigrated;
|
||||
}
|
||||
|
||||
try {
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final servers = await serverRegistry.getServers();
|
||||
|
||||
String accountLabel = 'Plex';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await _plexUserInfoFetcher(token);
|
||||
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
|
||||
}
|
||||
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: accountLabel,
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
|
||||
return conn;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate Plex Home users for [account] and select a virtual Plex Home
|
||||
/// profile. Plex users are never persisted as local Plezy profiles.
|
||||
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
|
||||
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
|
||||
users ??= await _fetchAndCachePlexHomeUsers(account);
|
||||
final hydratedUsers = users;
|
||||
if (hydratedUsers.isEmpty) return false;
|
||||
|
||||
final legacyActiveUuid = storage.getCurrentUserUUID();
|
||||
PlexHomeUser selected;
|
||||
if (legacyActiveUuid != null && legacyActiveUuid.isNotEmpty) {
|
||||
selected = hydratedUsers.firstWhere(
|
||||
(u) => u.uuid == legacyActiveUuid,
|
||||
orElse: () => _preferredPlexHomeUser(hydratedUsers),
|
||||
);
|
||||
if (selected.uuid != legacyActiveUuid) {
|
||||
appLogger.w('Migration: legacy Plex Home UUID $legacyActiveUuid not found; using ${selected.uuid} instead');
|
||||
}
|
||||
} else {
|
||||
selected = _preferredPlexHomeUser(hydratedUsers);
|
||||
}
|
||||
|
||||
final activeProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: selected.uuid);
|
||||
await storage.setActiveProfileId(activeProfileId);
|
||||
await storage.clearCurrentUserUUID();
|
||||
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
|
||||
appLogger.i('Migration: selected Plex Home profile ${selected.displayName} → $activeProfileId');
|
||||
return true;
|
||||
}
|
||||
|
||||
PlexHomeUser _preferredPlexHomeUser(List<PlexHomeUser> users) {
|
||||
return users.firstWhere((u) => u.admin, orElse: () => users.first);
|
||||
}
|
||||
|
||||
Future<bool> _migrateLegacyPlexHomeUsersCache(String connectionId) async {
|
||||
final raw = storage.prefs.getString('home_users_cache');
|
||||
if (raw == null || raw.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return false;
|
||||
final home = PlexHome.fromJson(decoded);
|
||||
if (home.users.isEmpty) return false;
|
||||
await storage.savePlexHomeUsersCache(connectionId, home.users.map((u) => u.toJson()).toList());
|
||||
appLogger.i('Migration: copied ${home.users.length} Plex Home users into cache for $connectionId');
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex Home cache migration failed', error: e, stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
|
||||
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return null;
|
||||
return decoded.whereType<Map<String, dynamic>>().map(PlexHomeUser.fromJson).toList();
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
|
||||
try {
|
||||
final users = await _plexHomeUserFetcher(account.accountToken);
|
||||
if (users.isNotEmpty) {
|
||||
await storage.savePlexHomeUsersCache(account.id, users.map((u) => u.toJson()).toList());
|
||||
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
|
||||
}
|
||||
return users;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
|
||||
if (storage.prefs.getString('home_users_cache') == null) return;
|
||||
var target = account;
|
||||
if (target == null) {
|
||||
final connections = await connectionRegistry.list();
|
||||
for (final conn in connections) {
|
||||
if (conn is PlexAccountConnection) {
|
||||
target = conn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target == null) return;
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(target.id);
|
||||
if (copied) {
|
||||
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure fresh non-Plex installs always have at least one local profile.
|
||||
/// Migrated Plex accounts select a virtual Plex Home profile instead.
|
||||
Future<void> _ensureOwnerProfile() async {
|
||||
final existing = await profileRegistry.list();
|
||||
if (existing.isNotEmpty) return;
|
||||
if (storage.getActiveProfileId() != null) return;
|
||||
|
||||
final owner = Profile(
|
||||
id: '$_ownerProfileIdPrefix${const Uuid().v4()}',
|
||||
kind: ProfileKind.local,
|
||||
displayName: 'Default',
|
||||
sortOrder: 0,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
await profileRegistry.upsert(owner);
|
||||
await storage.setActiveProfileId(owner.id);
|
||||
appLogger.i('Migration: created placeholder Default profile (no Plex account on first launch)');
|
||||
}
|
||||
|
||||
Future<PlexAccountConnection?> _firstPlexAccount(PlexAccountConnection? preferred) async {
|
||||
if (preferred != null) return preferred;
|
||||
final connections = await connectionRegistry.list();
|
||||
for (final conn in connections) {
|
||||
if (conn is PlexAccountConnection) return conn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _recoverLegacyProfilePromotionIfNeeded(PlexAccountConnection? account) async {
|
||||
if (!_hasLegacyProfileState()) return;
|
||||
final target = await _firstPlexAccount(account);
|
||||
if (target == null) return;
|
||||
await _preparePlexVirtualProfile(target);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final home = await auth.getHomeUsers(accountToken);
|
||||
return home.users;
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
return await auth.getUserInfo(accountToken);
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../services/credential_vault.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
|
||||
/// CRUD over the persisted [Connections] table. The registry is the source
|
||||
/// of truth for which connections the user has added; the runtime
|
||||
/// `MultiServerManager` populates per-server clients from these records.
|
||||
///
|
||||
/// Single-connection users get a default automatically — power users with
|
||||
/// multiple connections can override it via [setDefault].
|
||||
class ConnectionRegistry {
|
||||
ConnectionRegistry(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
/// Emits the current set of connections after every mutation. Drift's
|
||||
/// `watch()` provides this for free.
|
||||
Stream<List<Connection>> watchConnections() {
|
||||
return (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).watch().asyncMap(
|
||||
(rows) async => (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One-shot fetch of all stored connections.
|
||||
Future<List<Connection>> list() async {
|
||||
final rows = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
return (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList();
|
||||
}
|
||||
|
||||
/// Lookup a connection by id.
|
||||
Future<Connection?> get(String id) async {
|
||||
final row = await (_db.select(_db.connections)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
return _rowToConnection(row);
|
||||
}
|
||||
|
||||
/// Returns the user's preferred connection — either the row marked
|
||||
/// [Connections.isDefault], or the only row if exactly one exists, or
|
||||
/// null when no connections are stored.
|
||||
Future<Connection?> getDefault() async {
|
||||
final rows = await _db.select(_db.connections).get();
|
||||
if (rows.isEmpty) return null;
|
||||
final flagged = rows.firstWhereOrNull((r) => r.isDefault);
|
||||
final picked = flagged ?? (rows.length == 1 ? rows.single : null);
|
||||
return picked == null ? null : _rowToConnection(picked);
|
||||
}
|
||||
|
||||
/// Insert or replace [connection]. If this is the first stored connection
|
||||
/// it is automatically marked default; re-upserting an existing row keeps
|
||||
/// the row's current `isDefault` (so token/metadata refreshes don't clear
|
||||
/// the default flag).
|
||||
Future<void> upsert(Connection connection) async {
|
||||
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
|
||||
final bool isDefault;
|
||||
if (existing != null) {
|
||||
isDefault = existing.isDefault;
|
||||
} else {
|
||||
final any =
|
||||
await (_db.selectOnly(_db.connections)
|
||||
..addColumns([_db.connections.id])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
isDefault = any == null;
|
||||
}
|
||||
final protectedConfig = await CredentialVault.protectConnectionConfig(
|
||||
connection.kind.id,
|
||||
connection.toConfigJson(),
|
||||
);
|
||||
final row = ConnectionsCompanion(
|
||||
id: Value(connection.id),
|
||||
kind: Value(connection.kind.id),
|
||||
displayName: Value(connection.displayName),
|
||||
configJson: Value(jsonEncode(protectedConfig)),
|
||||
isDefault: Value(isDefault),
|
||||
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
|
||||
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
|
||||
);
|
||||
await _db.into(_db.connections).insertOnConflictUpdate(row);
|
||||
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
|
||||
}
|
||||
|
||||
/// Remove a stored connection. If the removed row was the default, the
|
||||
/// oldest remaining connection (if any) becomes default.
|
||||
Future<void> remove(String id) async {
|
||||
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
|
||||
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
}
|
||||
appLogger.d('ConnectionRegistry: removed $id');
|
||||
}
|
||||
|
||||
/// Set [id] as the default connection. Clears the flag on all others.
|
||||
Future<void> setDefault(String id) async {
|
||||
await _db.transaction(() async {
|
||||
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Update only the auth-related metadata on an existing row (token,
|
||||
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
|
||||
/// silent refresh without touching the rest of the config.
|
||||
Future<void> recordAuthSuccess(String id, DateTime at) async {
|
||||
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
|
||||
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _db.delete(_db.connections).go();
|
||||
}
|
||||
|
||||
/// All Plex accounts in insertion order. Convenience over
|
||||
/// `(await list()).whereType<PlexAccountConnection>()` — cuts ~3 lines from
|
||||
/// every caller that needs to filter by backend.
|
||||
Future<List<PlexAccountConnection>> listPlexAccounts() async {
|
||||
final all = await list();
|
||||
return all.whereType<PlexAccountConnection>().toList();
|
||||
}
|
||||
|
||||
/// All Jellyfin connections in insertion order. Symmetric helper to
|
||||
/// [listPlexAccounts].
|
||||
Future<List<JellyfinConnection>> listJellyfin() async {
|
||||
final all = await list();
|
||||
return all.whereType<JellyfinConnection>().toList();
|
||||
}
|
||||
|
||||
/// Lookup a [PlexAccountConnection] by id. Returns `null` if no row
|
||||
/// matches OR the row exists but isn't a Plex account.
|
||||
Future<PlexAccountConnection?> getPlexAccount(String id) async {
|
||||
final c = await get(id);
|
||||
return c is PlexAccountConnection ? c : null;
|
||||
}
|
||||
|
||||
/// Lookup a [JellyfinConnection] by id. Returns `null` if no row matches
|
||||
/// OR the row exists but isn't a Jellyfin connection.
|
||||
Future<JellyfinConnection?> getJellyfin(String id) async {
|
||||
final c = await get(id);
|
||||
return c is JellyfinConnection ? c : null;
|
||||
}
|
||||
|
||||
Future<Connection?> _rowToConnection(ConnectionRow row) async {
|
||||
try {
|
||||
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
|
||||
final kind = ConnectionKind.fromId(row.kind);
|
||||
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
|
||||
final lastAuth = row.lastAuthenticatedAt == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
|
||||
final connection = switch (kind) {
|
||||
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
};
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
return connection;
|
||||
} catch (e, st) {
|
||||
appLogger.e('ConnectionRegistry: failed to decode connection ${row.id}', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
-50
@@ -12,11 +12,46 @@ import '../utils/global_key_utils.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
/// String values stored in [OfflineWatchProgress.actionType] (use `.name`).
|
||||
enum OfflineActionType { progress, watched, unwatched }
|
||||
/// Action queued in the offline watch-progress sync table. The serialized
|
||||
/// form ([id]) is what gets persisted in [OfflineWatchProgress.actionType];
|
||||
/// keep these strings stable across renames so existing rows resolve.
|
||||
enum OfflineActionType {
|
||||
progress,
|
||||
watched,
|
||||
unwatched;
|
||||
|
||||
/// Stable string id used for persistence. Survives an enum-name rename
|
||||
/// (e.g. `progress` → `inProgress`) — `.name` would corrupt every row.
|
||||
String get id => switch (this) {
|
||||
OfflineActionType.progress => 'progress',
|
||||
OfflineActionType.watched => 'watched',
|
||||
OfflineActionType.unwatched => 'unwatched',
|
||||
};
|
||||
|
||||
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
|
||||
/// silently fall back to the wrong action.
|
||||
static OfflineActionType fromId(String id) => switch (id) {
|
||||
'progress' => OfflineActionType.progress,
|
||||
'watched' => OfflineActionType.watched,
|
||||
'unwatched' => OfflineActionType.unwatched,
|
||||
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
|
||||
};
|
||||
}
|
||||
|
||||
// Simplified database with API cache for offline support
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress, SyncRules])
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
DownloadedMedia,
|
||||
DownloadOwners,
|
||||
DownloadQueue,
|
||||
ApiCache,
|
||||
OfflineWatchProgress,
|
||||
SyncRules,
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@@ -26,11 +61,20 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase.forTesting(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 14;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
// Enforce ProfileConnections → Profiles/Connections cascades.
|
||||
// Drift turns FKs *off* during migrations, so the per-connection
|
||||
// pragma we set in `_openConnection` is wiped on first open. This
|
||||
// hook runs after migrations and re-enables it for subsequent
|
||||
// queries — also applies to in-memory test databases that don't go
|
||||
// through `_openConnection`.
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
@@ -41,19 +85,17 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 8) {
|
||||
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
|
||||
try {
|
||||
await m.addColumn(downloadedMedia, downloadedMedia.bgTaskId);
|
||||
} catch (e) {
|
||||
appLogger.w('bgTaskId column may already exist: $e');
|
||||
}
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.bgTaskId column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.bgTaskId),
|
||||
);
|
||||
}
|
||||
if (from < 9) {
|
||||
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
|
||||
try {
|
||||
await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex);
|
||||
} catch (e) {
|
||||
appLogger.w('mediaIndex column may already exist: $e');
|
||||
}
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.mediaIndex column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex),
|
||||
);
|
||||
}
|
||||
if (from < 10) {
|
||||
appLogger.i('Adding SyncRules table (v10 migration)');
|
||||
@@ -61,19 +103,14 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 11) {
|
||||
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
|
||||
try {
|
||||
await m.addColumn(syncRules, syncRules.enabled);
|
||||
} catch (e) {
|
||||
appLogger.w('enabled column may already exist: $e');
|
||||
}
|
||||
await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled));
|
||||
}
|
||||
if (from < 12) {
|
||||
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
|
||||
try {
|
||||
await m.addColumn(syncRules, syncRules.downloadFilter);
|
||||
} catch (e) {
|
||||
appLogger.w('downloadFilter column may already exist: $e');
|
||||
}
|
||||
await _ignoreAlreadyExists(
|
||||
'SyncRules.downloadFilter column',
|
||||
() => m.addColumn(syncRules, syncRules.downloadFilter),
|
||||
);
|
||||
}
|
||||
if (from < 13) {
|
||||
appLogger.i('Adding indexes on DownloadedMedia hot-queried columns (v13 migration)');
|
||||
@@ -84,38 +121,158 @@ class AppDatabase extends _$AppDatabase {
|
||||
'idx_downloaded_media_grandparent': idxDownloadedMediaGrandparent,
|
||||
};
|
||||
for (final entry in indexes.entries) {
|
||||
try {
|
||||
await m.create(entry.value);
|
||||
} catch (e) {
|
||||
appLogger.w('Index ${entry.key} may already exist: $e');
|
||||
}
|
||||
await _ignoreAlreadyExists('Index ${entry.key}', () => m.create(entry.value));
|
||||
}
|
||||
}
|
||||
if (from < 14) {
|
||||
appLogger.i(
|
||||
'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)',
|
||||
);
|
||||
|
||||
await m.createTable(connections);
|
||||
await m.create(idxConnectionsKind);
|
||||
|
||||
await m.createTable(profiles);
|
||||
await m.create(idxProfilesKind);
|
||||
|
||||
await m.createTable(profileConnections);
|
||||
await m.create(idxProfileConnectionsConnectionId);
|
||||
await m.create(idxProfileConnectionsProfileId);
|
||||
|
||||
await _ignoreAlreadyExists('DownloadOwners table', () => m.createTable(downloadOwners));
|
||||
await _ignoreAlreadyExists('Index idx_download_owners_profile', () => m.create(idxDownloadOwnersProfile));
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_download_owners_global_key',
|
||||
() => m.create(idxDownloadOwnersGlobalKey),
|
||||
);
|
||||
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.clientScopeId column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.clientScopeId),
|
||||
);
|
||||
await _ignoreAlreadyExists(
|
||||
'OfflineWatchProgress.clientScopeId column',
|
||||
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.clientScopeId),
|
||||
);
|
||||
await _ignoreAlreadyExists('SyncRules.profileId column', () => m.addColumn(syncRules, syncRules.profileId));
|
||||
await _ignoreAlreadyExists(
|
||||
'OfflineWatchProgress.profileId column',
|
||||
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.profileId),
|
||||
);
|
||||
|
||||
await customStatement('''
|
||||
UPDATE downloaded_media
|
||||
SET client_scope_id = (
|
||||
SELECT id FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE client_scope_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
|
||||
)
|
||||
''');
|
||||
await customStatement('''
|
||||
UPDATE offline_watch_progress
|
||||
SET client_scope_id = (
|
||||
SELECT id FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE client_scope_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
|
||||
)
|
||||
''');
|
||||
|
||||
await m.create(idxOfflineWatchProgressServer);
|
||||
await _ignoreAlreadyExists('Index idx_sync_rules_profile', () => m.create(idxSyncRulesProfile));
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_offline_watch_progress_profile',
|
||||
() => m.create(idxOfflineWatchProgressProfile),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ignoreAlreadyExists(String label, Future<void> Function() operation) async {
|
||||
try {
|
||||
await operation();
|
||||
} catch (e) {
|
||||
final message = e.toString().toLowerCase();
|
||||
if (message.contains('already exists') || message.contains('duplicate column name')) {
|
||||
appLogger.w('$label already exists during migration: $e');
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Offline Watch Progress Operations
|
||||
// ============================================================
|
||||
|
||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
|
||||
return value == null ? column.isNull() : column.equals(value);
|
||||
}
|
||||
|
||||
/// Get all pending offline watch actions for sync
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
|
||||
return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActions({String? profileId}) {
|
||||
final query = select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)]);
|
||||
if (profileId != null) {
|
||||
query.where((t) => t.profileId.equals(profileId));
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Claim pre-v18 offline watch actions for [profileId]. Those rows predate
|
||||
/// profile ownership and have `NULL profile_id`; the first active profile
|
||||
/// inherits them so already-watched offline progress is not stranded.
|
||||
Future<void> adoptLegacyOfflineWatchActionsForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await (update(
|
||||
offlineWatchProgress,
|
||||
)..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId)));
|
||||
}
|
||||
|
||||
/// Get pending watch actions for a specific server
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId) {
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId, {String? profileId}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where((t) => t.serverId.equals(serverId))
|
||||
..where(
|
||||
(t) =>
|
||||
t.serverId.equals(serverId) &
|
||||
(profileId == null ? const Constant(true) : t.profileId.equals(profileId)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get the latest action for a specific item
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(String globalKey) {
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(
|
||||
String globalKey, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.equals(globalKey))
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
@@ -125,19 +282,32 @@ class AppDatabase extends _$AppDatabase {
|
||||
///
|
||||
/// Returns a map of globalKey -> latest action for each key.
|
||||
/// Keys with no actions will not be present in the returned map.
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(Set<String> globalKeys) async {
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
|
||||
Set<String> globalKeys, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
Map<String, String?>? clientScopeIdsByGlobalKey,
|
||||
}) async {
|
||||
if (globalKeys.isEmpty) return {};
|
||||
|
||||
// Query all actions for the given keys
|
||||
final allActions =
|
||||
await (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.isIn(globalKeys))
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.isIn(globalKeys) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
|
||||
// Group by globalKey and take the latest (first due to ordering)
|
||||
final result = <String, OfflineWatchProgressItem>{};
|
||||
for (final action in allActions) {
|
||||
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
|
||||
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
|
||||
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
|
||||
}
|
||||
// Only keep the first (latest) action for each key
|
||||
result.putIfAbsent(action.globalKey, () => action);
|
||||
}
|
||||
@@ -145,9 +315,17 @@ class AppDatabase extends _$AppDatabase {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Insert or update a progress action (merges with existing)
|
||||
bool _clientScopeValuesMatch(String? actual, String? expected) {
|
||||
final normalizedActual = actual == null || actual.isEmpty ? null : actual;
|
||||
final normalizedExpected = expected == null || expected.isEmpty ? null : expected;
|
||||
return normalizedActual == normalizedExpected;
|
||||
}
|
||||
|
||||
/// Insert or update a progress action (merges with existing).
|
||||
Future<void> upsertProgressAction({
|
||||
String? profileId,
|
||||
required String serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required int viewOffset,
|
||||
required int duration,
|
||||
@@ -159,7 +337,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Check for existing progress entry
|
||||
final existing =
|
||||
await (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals(OfflineActionType.progress.name))
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId) &
|
||||
t.actionType.equals(OfflineActionType.progress.id),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
@@ -170,6 +354,8 @@ class AppDatabase extends _$AppDatabase {
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
updatedAt: Value(now),
|
||||
),
|
||||
);
|
||||
@@ -178,9 +364,11 @@ class AppDatabase extends _$AppDatabase {
|
||||
await into(offlineWatchProgress).insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: OfflineActionType.progress.name,
|
||||
actionType: OfflineActionType.progress.id,
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
@@ -191,10 +379,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a manual watch action (watched or unwatched)
|
||||
/// Removes conflicting actions for the same item
|
||||
/// Insert a manual watch action (watched or unwatched).
|
||||
/// Removes conflicting actions for the same item.
|
||||
Future<void> insertWatchAction({
|
||||
String? profileId,
|
||||
required String serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String actionType, // 'watched' or 'unwatched'
|
||||
}) async {
|
||||
@@ -202,12 +392,20 @@ class AppDatabase extends _$AppDatabase {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Remove conflicting actions (opposite action type and progress)
|
||||
await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(offlineWatchProgress)..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId),
|
||||
))
|
||||
.go();
|
||||
|
||||
// Insert the new action
|
||||
await into(offlineWatchProgress).insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: actionType,
|
||||
@@ -234,10 +432,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
/// Get count of pending sync items
|
||||
Future<int> getPendingSyncCount() async {
|
||||
final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]))
|
||||
.map((row) => row.read(offlineWatchProgress.id.count()))
|
||||
.getSingle();
|
||||
Future<int> getPendingSyncCount({String? profileId}) async {
|
||||
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
|
||||
if (profileId != null) {
|
||||
query.where(offlineWatchProgress.profileId.equals(profileId));
|
||||
}
|
||||
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
@@ -250,8 +450,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Sync Rules Operations
|
||||
// ============================================================
|
||||
|
||||
Future<List<SyncRuleItem>> getSyncRules() {
|
||||
return select(syncRules).get();
|
||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||
final query = select(syncRules);
|
||||
if (profileId != null) {
|
||||
query.where((t) => t.profileId.equals(profileId));
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
Future<SyncRuleItem?> getSyncRule(String globalKey) {
|
||||
@@ -259,6 +463,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
Future<void> insertSyncRule({
|
||||
String profileId = '',
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
@@ -267,9 +472,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
int mediaIndex = 0,
|
||||
String downloadFilter = 'unwatched',
|
||||
}) async {
|
||||
await into(syncRules).insertOnConflictUpdate(
|
||||
// [insertOnConflictUpdate] defaults the conflict target to the primary
|
||||
// key (`id`), which is auto-incremented — the conflict never triggers
|
||||
// and the row's UNIQUE [globalKey] constraint blows up instead. Drive
|
||||
// the upsert off the public [globalKey] so re-creating a rule for the same
|
||||
// shared target updates the existing row.
|
||||
await into(syncRules).insert(
|
||||
SyncRulesCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
@@ -278,9 +489,39 @@ class AppDatabase extends _$AppDatabase {
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SyncRulesCompanion(
|
||||
serverId: Value(serverId),
|
||||
profileId: Value(profileId),
|
||||
ratingKey: Value(ratingKey),
|
||||
targetType: Value(targetType),
|
||||
episodeCount: Value(episodeCount),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
),
|
||||
target: [syncRules.globalKey],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Claim pre-v16 public sync rules for [profileId]. Rules created before
|
||||
/// profile ownership have an empty profile id and a public global key.
|
||||
Future<void> adoptLegacySyncRulesForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get();
|
||||
for (final rule in legacyRules) {
|
||||
final scopedKey = buildProfileScopedGlobalKey(profileId, rule.serverId, rule.ratingKey);
|
||||
final duplicate = await getSyncRule(scopedKey);
|
||||
if (duplicate != null) {
|
||||
await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go();
|
||||
continue;
|
||||
}
|
||||
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
|
||||
SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
@@ -346,6 +587,9 @@ LazyDatabase _openConnection() {
|
||||
setup: (db) {
|
||||
db.execute('PRAGMA journal_mode=WAL');
|
||||
db.execute('PRAGMA synchronous=NORMAL');
|
||||
// Enforce ProfileConnections → Profiles/Connections cascades.
|
||||
// SQLite requires this on every connection — it's not persisted.
|
||||
db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+4800
-525
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,84 @@ import 'package:drift/drift.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../profiles/profile.dart';
|
||||
|
||||
/// Extension methods on AppDatabase for download operations
|
||||
extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Insert a new download into the database
|
||||
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await into(downloadOwners).insert(
|
||||
DownloadOwnersCompanion.insert(
|
||||
profileId: profileId,
|
||||
globalKey: globalKey,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
Future<void> removeDownloadOwnersForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go();
|
||||
}
|
||||
|
||||
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return const {};
|
||||
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
|
||||
return rows.map((row) => row.globalKey).toSet();
|
||||
}
|
||||
|
||||
Future<int> getDownloadOwnerCount(String globalKey) async {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
|
||||
if (rows.isEmpty) return const [];
|
||||
final candidates = rows
|
||||
.where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId)
|
||||
.toList(growable: false);
|
||||
if (candidates.isEmpty) return const [];
|
||||
|
||||
final localProfileRows = await select(profiles).get();
|
||||
final localProfileIds = localProfileRows.map((row) => row.id).toSet();
|
||||
final connectionRows = await select(connections).get();
|
||||
final connectionIds = connectionRows.map((row) => row.id).toSet();
|
||||
return candidates
|
||||
.where((row) {
|
||||
if (localProfileIds.contains(row.profileId)) return true;
|
||||
final plexHome = parsePlexHomeProfileId(row.profileId);
|
||||
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
|
||||
return localProfileIds.isEmpty;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Claim pre-v17 shared download rows for [profileId]. Rows that already
|
||||
/// have any owner are left untouched so later profiles do not inherit them.
|
||||
Future<void> adoptLegacyDownloadsForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
final rows = await select(downloadedMedia).get();
|
||||
for (final row in rows) {
|
||||
if (await getDownloadOwnerCount(row.globalKey) == 0) {
|
||||
await addDownloadOwner(profileId: profileId, globalKey: row.globalKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new download into the database.
|
||||
Future<void> insertDownload({
|
||||
required String serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
@@ -19,6 +91,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: type,
|
||||
@@ -135,18 +208,41 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
||||
String seasonKey, {
|
||||
String? serverId,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(downloadedMedia)..where(
|
||||
(t) =>
|
||||
t.parentRatingKey.equals(seasonKey) &
|
||||
_optionalServerPredicate(t.serverId, serverId) &
|
||||
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
||||
String showKey, {
|
||||
String? serverId,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(downloadedMedia)..where(
|
||||
(t) =>
|
||||
t.grandparentRatingKey.equals(showKey) &
|
||||
_optionalServerPredicate(t.serverId, serverId) &
|
||||
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded items for a specific server
|
||||
@@ -154,6 +250,24 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
|
||||
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, String? serverId) {
|
||||
return serverId == null ? const Constant(true) : column.equals(serverId);
|
||||
}
|
||||
|
||||
Expression<bool> _optionalClientScopePredicate(
|
||||
GeneratedColumn<String> column,
|
||||
String? clientScopeId, {
|
||||
required bool filterClientScope,
|
||||
}) {
|
||||
if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) {
|
||||
return const Constant(true);
|
||||
}
|
||||
if (clientScopeId == null || clientScopeId.isEmpty) {
|
||||
return column.isNull() | column.equals('');
|
||||
}
|
||||
return column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(
|
||||
|
||||
+145
-3
@@ -1,9 +1,10 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Key-value cache table for Plex API responses.
|
||||
/// Key-value cache table for media-server API responses (Plex, Jellyfin).
|
||||
/// Used for offline support - stores raw JSON responses.
|
||||
class ApiCache extends Table {
|
||||
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345")
|
||||
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345"
|
||||
/// for Plex, "abc123:/Users/.../Items/..." for Jellyfin)
|
||||
TextColumn get cacheKey => text()();
|
||||
|
||||
/// JSON response data
|
||||
@@ -37,6 +38,12 @@ class DownloadQueue extends Table {
|
||||
class DownloadedMedia extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get serverId => text()();
|
||||
// Downloads are intentionally app-wide/shared, keyed by the public
|
||||
// serverId:ratingKey globalKey below. For Jellyfin, clientScopeId records
|
||||
// which scoped client produced the cached metadata/download request; it is
|
||||
// not part of download ownership and must not be used to hide or duplicate
|
||||
// the physical downloaded item per user/profile.
|
||||
TextColumn get clientScopeId => text().nullable()();
|
||||
TextColumn get ratingKey => text()();
|
||||
TextColumn get globalKey => text().unique()();
|
||||
TextColumn get type => text()();
|
||||
@@ -55,13 +62,35 @@ class DownloadedMedia extends Table {
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
}
|
||||
|
||||
/// Profile ownership for shared physical downloads.
|
||||
///
|
||||
/// [DownloadedMedia] stores one physical row per public serverId:ratingKey so
|
||||
/// files are deduped across profiles. This table controls which active profile
|
||||
/// can see/use that shared row.
|
||||
@DataClassName('DownloadOwnerItem')
|
||||
@TableIndex(name: 'idx_download_owners_profile', columns: {#profileId})
|
||||
@TableIndex(name: 'idx_download_owners_global_key', columns: {#globalKey})
|
||||
class DownloadOwners extends Table {
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get globalKey => text()();
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId, globalKey};
|
||||
}
|
||||
|
||||
/// Persistent sync rules for auto-downloading unwatched episodes.
|
||||
///
|
||||
/// Each rule keeps a rolling window of N unwatched episodes for a show/season,
|
||||
/// or mirrors the current contents of a collection/playlist.
|
||||
/// Rules are owned by the active top-level profile. Downloads remain app-wide
|
||||
/// and shared by public serverId:ratingKey identity, but rule ownership must not
|
||||
/// cross users because Jellyfin permissions and watch state are user-scoped.
|
||||
@DataClassName('SyncRuleItem')
|
||||
@TableIndex(name: 'idx_sync_rules_profile', columns: {#profileId})
|
||||
class SyncRules extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get profileId => text().withDefault(const Constant(''))();
|
||||
TextColumn get serverId => text()();
|
||||
TextColumn get ratingKey => text()();
|
||||
TextColumn get globalKey => text().unique()();
|
||||
@@ -74,18 +103,131 @@ class SyncRules extends Table {
|
||||
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
|
||||
}
|
||||
|
||||
/// Persisted media-server connections.
|
||||
///
|
||||
/// One row per "connection" the user has added — a Plex account (with its
|
||||
/// discovered servers and active Home profile) or a single Jellyfin server.
|
||||
/// The [configJson] payload is backend-specific and parsed by the
|
||||
/// [Connection] sealed class.
|
||||
@DataClassName('ConnectionRow')
|
||||
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
|
||||
class Connections extends Table {
|
||||
/// Stable identifier for the connection. For Plex it's a generated UUID
|
||||
/// (one per account); for Jellyfin it's the server's machineId.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Backend kind: `'plex'` or `'jellyfin'`.
|
||||
TextColumn get kind => text()();
|
||||
|
||||
/// User-visible label (account email, server name).
|
||||
TextColumn get displayName => text()();
|
||||
|
||||
/// Backend-specific config payload (token, baseUrl, profile id, …).
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
/// Whether this is the default connection used at app launch when only
|
||||
/// one connection is present.
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp this connection was added (milliseconds since epoch).
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
/// Timestamp of the most-recent successful auth refresh (milliseconds
|
||||
/// since epoch). Null until the first successful auth.
|
||||
IntColumn get lastAuthenticatedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Top-level profiles — see [Profile].
|
||||
///
|
||||
/// A profile is the user-facing identity. Plex Home users auto-surface as
|
||||
/// `kind='plex_home'` rows when their parent Plex account is added; users
|
||||
/// can also create `kind='local'` rows manually. Each profile owns one or
|
||||
/// more connections via [ProfileConnections].
|
||||
@DataClassName('ProfileRow')
|
||||
@TableIndex(name: 'idx_profiles_kind', columns: {#kind})
|
||||
class Profiles extends Table {
|
||||
/// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}`
|
||||
/// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// `'local'` | `'plex_home'`.
|
||||
TextColumn get kind => text()();
|
||||
|
||||
TextColumn get displayName => text()();
|
||||
|
||||
/// Plex Home users have a thumb URL; locals fall back to initials/colour.
|
||||
TextColumn get avatarThumbUrl => text().nullable()();
|
||||
|
||||
/// Per-kind config:
|
||||
/// - `local`: `{ "pinHash": "..." }`
|
||||
/// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }`
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||||
IntColumn get createdAt => integer()();
|
||||
IntColumn get lastUsedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Many-to-many join between [Profiles] and [Connections], carrying the
|
||||
/// per-profile user-level token used when the profile is active.
|
||||
///
|
||||
/// For Plex: `userToken` is a Home-user token from `/home/users/{uuid}/switch`,
|
||||
/// `userIdentifier` is the Plex Home user UUID. An empty `userToken` is a
|
||||
/// lazy-fetch sentinel — the binder calls `/switch` on first activation.
|
||||
/// The Dart-side [ProfileConnection] model surfaces empty as `null`; the
|
||||
/// column stays non-nullable here to avoid a schema migration.
|
||||
///
|
||||
/// For Jellyfin: `userToken` mirrors the Connection's accessToken (one user
|
||||
/// per Jellyfin connection) and `userIdentifier` is the Jellyfin user id.
|
||||
@DataClassName('ProfileConnectionRow')
|
||||
@TableIndex(name: 'idx_profile_connections_connection_id', columns: {#connectionId})
|
||||
@TableIndex(name: 'idx_profile_connections_profile_id', columns: {#profileId})
|
||||
class ProfileConnections extends Table {
|
||||
// No FK on profile_id: Plex Home profiles are virtual (built by
|
||||
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
||||
// persisted in `profiles`), so an FK here would reject every join row
|
||||
// they need. The two profile-delete sites clean up join rows manually
|
||||
// via ProfileConnectionRegistry.removeAllForProfile before calling
|
||||
// ProfileRegistry.remove.
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
||||
TextColumn get userToken => text().withDefault(const Constant(''))();
|
||||
TextColumn get userIdentifier => text()();
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
IntColumn get tokenAcquiredAt => integer().nullable()();
|
||||
IntColumn get lastUsedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId, connectionId};
|
||||
}
|
||||
|
||||
/// Queue for offline watch progress and manual watch actions.
|
||||
///
|
||||
/// Stores watch progress updates and manual watch/unwatch actions
|
||||
/// that need to be synced to the Plex server when back online.
|
||||
/// that need to be synced to the originating media server when back online.
|
||||
@DataClassName('OfflineWatchProgressItem')
|
||||
@TableIndex(name: 'idx_offline_watch_progress_server', columns: {#serverId})
|
||||
@TableIndex(name: 'idx_offline_watch_progress_profile', columns: {#profileId})
|
||||
class OfflineWatchProgress extends Table {
|
||||
/// Auto-incrementing primary key
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
/// Active Plezy profile that owns this queued action.
|
||||
TextColumn get profileId => text().nullable()();
|
||||
|
||||
/// Server ID this media belongs to
|
||||
TextColumn get serverId => text()();
|
||||
|
||||
/// Optional user-scoped client/cache id for backends where [serverId] is
|
||||
/// shared by multiple users on the same server.
|
||||
TextColumn get clientScopeId => text().nullable()();
|
||||
|
||||
/// Rating key of the media item
|
||||
TextColumn get ratingKey => text()();
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
/// Sealed base for backend-agnostic media-server exceptions. Both Plex and
|
||||
/// Jellyfin auth/HTTP layers throw subtypes from this hierarchy so consumers
|
||||
/// can catch with one filter and match exhaustively when they care which
|
||||
/// failure mode it is.
|
||||
sealed class MediaServerException implements Exception {
|
||||
final String message;
|
||||
const MediaServerException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
}
|
||||
|
||||
/// The supplied base URL is unreachable, returns the wrong shape, or doesn't
|
||||
/// look like the expected backend at all. Surfaces in onboarding probes
|
||||
/// (Jellyfin `/System/Info/Public`, Plex resource discovery).
|
||||
class MediaServerUrlException extends MediaServerException {
|
||||
const MediaServerUrlException(super.message);
|
||||
}
|
||||
|
||||
/// Authentication failed — bad password, expired token, disabled user,
|
||||
/// rate-limit. [statusCode] is the HTTP status when the failure was a 4xx
|
||||
/// response; null for transport-layer auth signals (e.g. token rejected
|
||||
/// during refresh).
|
||||
class MediaServerAuthException extends MediaServerException {
|
||||
final int? statusCode;
|
||||
const MediaServerAuthException(super.message, {this.statusCode});
|
||||
}
|
||||
|
||||
/// HTTP transport / non-2xx errors. Carries the status code (when known),
|
||||
/// the parsed response body, and the originating URI so callers can log
|
||||
/// useful diagnostics. Both Plex and Jellyfin route their HTTP failures
|
||||
/// through this type — it's the canonical backend-agnostic transport
|
||||
/// exception.
|
||||
enum MediaServerHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown }
|
||||
|
||||
class MediaServerHttpException extends MediaServerException {
|
||||
final MediaServerHttpErrorType type;
|
||||
final int? statusCode;
|
||||
final dynamic responseData;
|
||||
final Uri? requestUri;
|
||||
|
||||
MediaServerHttpException({required this.type, String? message, this.statusCode, this.responseData, this.requestUri})
|
||||
: super(message ?? '');
|
||||
|
||||
/// Map a caught exception to a [MediaServerHttpException].
|
||||
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
|
||||
if (error is MediaServerHttpException) return error;
|
||||
|
||||
if (error is RequestAbortedException) {
|
||||
return MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.cancelled,
|
||||
message: error.message,
|
||||
requestUri: error.uri ?? uri,
|
||||
);
|
||||
}
|
||||
|
||||
if (error is TimeoutException) {
|
||||
return MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionTimeout,
|
||||
message: error.message,
|
||||
requestUri: uri,
|
||||
);
|
||||
}
|
||||
|
||||
if (error is SocketException) {
|
||||
return MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: error.message,
|
||||
requestUri: uri,
|
||||
);
|
||||
}
|
||||
|
||||
if (error is HttpException) {
|
||||
return MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: error.message,
|
||||
requestUri: uri,
|
||||
);
|
||||
}
|
||||
|
||||
if (error is ClientException) {
|
||||
return MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: error.message,
|
||||
requestUri: error.uri ?? uri,
|
||||
);
|
||||
}
|
||||
|
||||
return MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri);
|
||||
}
|
||||
|
||||
/// Whether the error looks transient (network/timeout) and worth retrying.
|
||||
bool get isTransient =>
|
||||
type == MediaServerHttpErrorType.connectionTimeout ||
|
||||
type == MediaServerHttpErrorType.connectionError ||
|
||||
type == MediaServerHttpErrorType.receiveTimeout;
|
||||
|
||||
@override
|
||||
String toString() => 'MediaServerHttpException(${type.name}: $message)';
|
||||
}
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Log ind",
|
||||
"signInWithPlex": "Log ind med Plex",
|
||||
"showQRCode": "Vis QR-kode",
|
||||
"authenticate": "Godkend",
|
||||
"authenticationTimeout": "Godkendelse fik timeout. Prøv igen.",
|
||||
"scanQRToSignIn": "Scan denne QR-kode for at logge ind",
|
||||
"waitingForAuth": "Venter på godkendelse...\nFærdiggør login i din browser.",
|
||||
"useBrowser": "Brug browser"
|
||||
"useBrowser": "Brug browser",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Forbind til Jellyfin",
|
||||
"useQuickConnect": "Brug Quick Connect",
|
||||
"quickConnectCode": "Quick Connect-kode",
|
||||
"quickConnectInstructions": "Åbn din Jellyfin-server i en webbrowser, log ind, og vælg Quick Connect i brugermenuen. Indtast denne kode for at godkende loginnet.",
|
||||
"quickConnectWaiting": "Venter på godkendelse…",
|
||||
"quickConnectCancel": "Annullér",
|
||||
"quickConnectExpired": "Quick Connect-koden udløb inden godkendelse. Prøv igen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuller",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Gitter",
|
||||
"listView": "Liste",
|
||||
"showHeroSection": "Vis hero-sektion",
|
||||
"useGlobalHubs": "Brug Plex Home-layout",
|
||||
"useGlobalHubsDescription": "Vis startsidehubbe som den officielle Plex-klient. Når slået fra, vises anbefalinger per bibliotek.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Vis servernavn på hubbe",
|
||||
"showServerNameOnHubsDescription": "Vis altid servernavnet i hubtitler. Når slået fra, vises kun ved duplikerede navne.",
|
||||
"groupLibrariesByServer": "Grupper biblioteker efter server",
|
||||
"groupLibrariesByServerDescription": "Vis en overskrift for hver Plex-server i sidepanelet, når du er forbundet til flere servere.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Hold altid sidepanelet åbent",
|
||||
"alwaysKeepSidebarOpenDescription": "Sidepanelet forbliver udvidet, og indholdsområdet tilpasser sig",
|
||||
"showUnwatchedCount": "Vis antal usete",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Musikafspilning understøttes endnu ikke",
|
||||
"noDescriptionAvailable": "Ingen beskrivelse tilgængelig",
|
||||
"noProfilesAvailable": "Ingen profiler tilgængelige",
|
||||
"contactAdminForProfiles": "Kontakt din Plex-administrator for at tilføje profiler",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Kan ikke bestemme biblioteksafdeling for dette element",
|
||||
"logsCleared": "Logs ryddet",
|
||||
"logsCopied": "Logs kopieret til udklipsholder",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Bekræft handling"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Tilføj Plezy-profil",
|
||||
"switchingProfile": "Skifter profil…",
|
||||
"deleteThisProfileTitle": "Slet denne profil?",
|
||||
"deleteThisProfileMessage": "${displayName} fjernes. Forbindelser påvirkes ikke.",
|
||||
"active": "Aktiv",
|
||||
"manage": "Administrer",
|
||||
"delete": "Slet",
|
||||
"signOut": "Log ud",
|
||||
"signOutPlexTitle": "Log ud af Plex?",
|
||||
"signOutPlexMessage": "${displayName} og alle Plex Home-brugere på denne konto fjernes fra denne enhed. Du kan logge ind igen når som helst.",
|
||||
"signedOutPlex": "Logget ud af Plex.",
|
||||
"signOutFailed": "Log ud mislykkedes.",
|
||||
"sectionTitle": "Profiler",
|
||||
"summarySingle": "Tilføj profiler for at blande administrerede brugere og lokale identiteter",
|
||||
"summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}",
|
||||
"summaryMultiple": "${count} profiler",
|
||||
"removeConnectionTitle": "Fjern forbindelse?",
|
||||
"removeConnectionMessage": "${displayName} mister adgang til ${connectionLabel}. Forbindelsen forbliver tilgængelig for andre profiler.",
|
||||
"deleteProfileTitle": "Slet profil?",
|
||||
"deleteProfileMessage": "Dette fjerner ${displayName} og alle dens forbindelser fra denne enhed. De underliggende Plex/Jellyfin-servere påvirkes ikke.",
|
||||
"profileNameLabel": "Profilnavn",
|
||||
"pinProtectionLabel": "PIN-beskyttelse",
|
||||
"pinManagedByPlex": "PIN administreres af Plex. Rediger på plex.tv.",
|
||||
"noPinSetEditOnPlex": "Ingen PIN-kode angivet. For at kræve en, redigér Home-brugeren på plex.tv.",
|
||||
"setPin": "Angiv PIN",
|
||||
"connectionsLabel": "Forbindelser",
|
||||
"add": "Tilføj",
|
||||
"deleteProfileButton": "Slet profil",
|
||||
"noConnectionsHint": "Ingen forbindelser — tilføj en for at bruge denne profil.",
|
||||
"plexHomeAccount": "Plex Home-konto",
|
||||
"connectionDefault": "Standard",
|
||||
"makeDefault": "Gør til standard",
|
||||
"removeConnection": "Fjern",
|
||||
"borrowAddTo": "Tilføj til ${displayName}",
|
||||
"borrowExplain": "Lån en forbindelse fra en anden profil. PIN-beskyttede kildeprofiler beder om PIN, før de deler.",
|
||||
"borrowEmpty": "Intet at låne endnu.",
|
||||
"borrowEmptySubtitle": "Tilslut først en Plex-konto eller Jellyfin-server til en anden profil, og kom så tilbage hertil.",
|
||||
"newProfile": "Ny profil",
|
||||
"profileNameHint": "fx. Gæster, Børn, Familiens stue",
|
||||
"pinProtectionOptional": "PIN-beskyttelse (valgfri)",
|
||||
"pinExplain": "4-cifret PIN-kode kræves for at skifte til denne profil. Blød barriere — enhver der kan slette appdata, kan omgå den.",
|
||||
"continueButton": "Fortsæt",
|
||||
"pinsDontMatch": "PIN-koder matcher ikke"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Forbindelser",
|
||||
"addConnection": "Tilføj forbindelse",
|
||||
"addConnectionSubtitleNoProfile": "Log ind med Plex eller forbind til en Jellyfin-server",
|
||||
"addConnectionSubtitleScoped": "Tilføj til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en anden profil",
|
||||
"sessionExpiredOne": "Sessionen er udløbet for ${name}",
|
||||
"sessionExpiredMany": "Sessionen er udløbet for ${count} servere",
|
||||
"signInAgain": "Log ind igen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Opdag",
|
||||
"switchProfile": "Skift profil",
|
||||
"noContentAvailable": "Intet indhold tilgængeligt",
|
||||
"addMediaToLibraries": "Tilføj medier til dine biblioteker",
|
||||
"continueWatching": "Fortsæt med at se",
|
||||
"nextUp": "Næste op",
|
||||
"recentlyAdded": "Nyligt tilføjet",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Oversigt",
|
||||
"cast": "Rollebesætning",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Søgning mislykkedes: ${error}",
|
||||
"connectionTimeout": "Forbindelsestimeout ved indlæsning af ${context}",
|
||||
"connectionFailed": "Kunne ikke forbinde til Plex-server",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Kunne ikke indlæse ${context}: ${error}",
|
||||
"noClientAvailable": "Ingen klient tilgængelig",
|
||||
"authenticationFailed": "Godkendelse mislykkedes: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Indtast et token",
|
||||
"invalidToken": "Ugyldigt token",
|
||||
"failedToVerifyToken": "Kunne ikke verificere token: ${error}",
|
||||
"failedToSwitchProfile": "Kunne ikke skifte til ${displayName}"
|
||||
"failedToSwitchProfile": "Kunne ikke skifte til ${displayName}",
|
||||
"failedToDeleteProfile": "Kunne ikke slette ${displayName}",
|
||||
"failedToRate": "Kunne ikke opdatere bedømmelsen"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Biblioteker",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Sæsoner",
|
||||
"episodes": "Episoder",
|
||||
"folders": "Mapper"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "År",
|
||||
"contentRating": "Aldersvurdering",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titel",
|
||||
"dateAdded": "Tilføjet dato",
|
||||
"releaseDate": "Udgivelsesdato",
|
||||
"rating": "Vurdering",
|
||||
"lastPlayed": "Sidst afspillet",
|
||||
"playCount": "Antal afspilninger",
|
||||
"random": "Tilfældig",
|
||||
"dateShared": "Delt dato",
|
||||
"latestEpisodeAirDate": "Seneste episodes premieredato"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Om",
|
||||
"openSourceLicenses": "Open source-licenser",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "En smuk Plex-klient til Flutter",
|
||||
"appDescription": "En smuk Plex- og Jellyfin-klient til Flutter",
|
||||
"viewLicensesDescription": "Se licenser for tredjepartsbiblioteker"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Venter på at andre indlæser...",
|
||||
"recentRooms": "Seneste rum",
|
||||
"renameRoom": "Omdøb rum",
|
||||
"removeRoom": "Fjern"
|
||||
"removeRoom": "Fjern",
|
||||
"guestSwitchUnavailable": "Kunne ikke skifte — server ikke tilgængelig for synkronisering",
|
||||
"guestSwitchFailed": "Kunne ikke skifte — indhold blev ikke fundet på denne server"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Synkroniseringsfilter",
|
||||
"syncAllItems": "Synkroniserer alle elementer",
|
||||
"syncUnwatchedItems": "Synkroniserer usete elementer",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Tilgængelig",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Log ind påkrævet",
|
||||
"syncRuleNotAvailableForProfile": "Ikke tilgængelig for nuværende profil",
|
||||
"syncRuleUnknownServer": "Ukendt server",
|
||||
"syncRuleListCreated": "Synkroniseringsregel oprettet"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Biblioteker",
|
||||
"noLibraries": "Ingen biblioteker tilgængelige"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Tilføj Jellyfin-server",
|
||||
"jellyfinUrlIntro": "Angiv URL'en til din Jellyfin-server — f.eks. `https://jellyfin.example.com`. Du kan logge ind bagefter.",
|
||||
"serverUrl": "Server-URL",
|
||||
"findServer": "Find server",
|
||||
"username": "Brugernavn",
|
||||
"password": "Adgangskode",
|
||||
"signIn": "Log ind",
|
||||
"change": "Ændr",
|
||||
"required": "Påkrævet",
|
||||
"couldNotReachServer": "Kunne ikke nå serveren: ${error}",
|
||||
"signInFailed": "Login mislykkedes: ${error}",
|
||||
"quickConnectFailed": "Quick Connect mislykkedes: ${error}",
|
||||
"addPlexTitle": "Log ind med Plex",
|
||||
"plexAuthIntro": "Vælg hvordan du vil logge ind på Plex. Browser-flowet åbner plex.tv, hvor du bekræfter forbindelsen; QR-koden er praktisk til TV / fjern-enheder.",
|
||||
"plexQRPrompt": "Scan denne QR-kode for at logge ind.",
|
||||
"waitingForPlexConfirmation": "Venter på at plex.tv bekræfter login…",
|
||||
"pinExpired": "PIN udløb før login. Prøv igen.",
|
||||
"duplicatePlexAccount": "Denne enhed er allerede logget ind på en Plex-konto. Log ud fra indstillingerne for at skifte konto.",
|
||||
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
|
||||
"enterJellyfinUrlError": "Angiv URL'en til din Jellyfin-server",
|
||||
"addConnectionTitle": "Tilføj forbindelse",
|
||||
"addConnectionTitleScoped": "Tilføj til ${name}",
|
||||
"addConnectionIntroGlobal": "Tilføj endnu en medieserver. Du kan blande Plex-konti og Jellyfin-servere — indhold fra alle tilkoblede backender vises samlet på startsiden.",
|
||||
"addConnectionIntroScoped": "Tilføj en ny server, eller lån en fra en anden profil.",
|
||||
"signInWithPlexCard": "Log ind med Plex",
|
||||
"signInWithPlexCardSubtitle": "Godkend denne enhed mod din Plex-konto. Servere delt med kontoen følger med automatisk.",
|
||||
"signInWithPlexCardSubtitleScoped": "Godkend en ny Plex-konto. Dens Home-brugere vises som profiler.",
|
||||
"connectToJellyfinCard": "Forbind til Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Angiv URL'en til din Jellyfin-server og log ind med brugernavn + adgangskode (Quick Connect kommer snart).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Log ind på en Jellyfin-server. Tilknyttes ${name}.",
|
||||
"borrowFromAnotherProfile": "Lån fra en anden profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Genbrug en forbindelse, der allerede er tilknyttet en anden profil. PIN-beskyttede kilde-profiler beder om PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Anmelden",
|
||||
"signInWithPlex": "Mit Plex anmelden",
|
||||
"showQRCode": "QR-Code anzeigen",
|
||||
"authenticate": "Authentifizieren",
|
||||
"authenticationTimeout": "Authentifizierung abgelaufen. Bitte erneut versuchen.",
|
||||
"scanQRToSignIn": "QR-Code scannen zum Anmelden",
|
||||
"waitingForAuth": "Warte auf Authentifizierung...\nBitte Anmeldung im Browser abschließen.",
|
||||
"useBrowser": "Browser verwenden"
|
||||
"useBrowser": "Browser verwenden",
|
||||
"or": "oder",
|
||||
"connectToJellyfin": "Mit Jellyfin verbinden",
|
||||
"useQuickConnect": "Quick Connect verwenden",
|
||||
"quickConnectCode": "Quick Connect-Code",
|
||||
"quickConnectInstructions": "Öffne deinen Jellyfin-Server im Browser, melde dich an und wähle Quick Connect im Benutzermenü. Gib diesen Code ein, um die Anmeldung zu bestätigen.",
|
||||
"quickConnectWaiting": "Warte auf Bestätigung…",
|
||||
"quickConnectCancel": "Abbrechen",
|
||||
"quickConnectExpired": "Quick Connect-Code ist vor der Bestätigung abgelaufen. Bitte erneut versuchen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Raster",
|
||||
"listView": "Liste",
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"useGlobalHubs": "Plex-Startseiten-Layout verwenden",
|
||||
"useGlobalHubsDescription": "Zeigt Startseiten-Hubs wie der offizielle Plex-Client. Wenn deaktiviert, werden stattdessen Empfehlungen pro Bibliothek angezeigt.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Servername bei Hubs anzeigen",
|
||||
"showServerNameOnHubsDescription": "Zeigt immer den Servernamen in Hub-Titeln an. Wenn deaktiviert, nur bei doppelten Hub-Namen.",
|
||||
"groupLibrariesByServer": "Mediatheken nach Server gruppieren",
|
||||
"groupLibrariesByServerDescription": "Zeigt eine Überschrift für jeden Plex-Server in der Seitenleiste an, wenn du mit mehreren Servern verbunden bist.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Seitenleiste immer geöffnet halten",
|
||||
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
|
||||
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
|
||||
"noDescriptionAvailable": "Keine Beschreibung verfügbar",
|
||||
"noProfilesAvailable": "Keine Profile verfügbar",
|
||||
"contactAdminForProfiles": "Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Bibliotheksbereich für dieses Element kann nicht ermittelt werden",
|
||||
"logsCleared": "Protokolle gelöscht",
|
||||
"logsCopied": "Protokolle in Zwischenablage kopiert",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Aktion bestätigen"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Plezy-Profil hinzufügen",
|
||||
"switchingProfile": "Profil wird gewechselt…",
|
||||
"deleteThisProfileTitle": "Dieses Profil löschen?",
|
||||
"deleteThisProfileMessage": "${displayName} wird entfernt. Verbindungen sind davon nicht betroffen.",
|
||||
"active": "Aktiv",
|
||||
"manage": "Verwalten",
|
||||
"delete": "Löschen",
|
||||
"signOut": "Abmelden",
|
||||
"signOutPlexTitle": "Von Plex abmelden?",
|
||||
"signOutPlexMessage": "${displayName} und alle Plex Home-Benutzer dieses Kontos werden von diesem Gerät entfernt. Du kannst dich jederzeit wieder anmelden.",
|
||||
"signedOutPlex": "Von Plex abgemeldet.",
|
||||
"signOutFailed": "Abmeldung fehlgeschlagen.",
|
||||
"sectionTitle": "Profile",
|
||||
"summarySingle": "Profile hinzufügen, um verwaltete Benutzer und lokale Identitäten zu mischen",
|
||||
"summaryMultipleWithActive": "${count} Profile · aktiv: ${activeName}",
|
||||
"summaryMultiple": "${count} Profile",
|
||||
"removeConnectionTitle": "Verbindung entfernen?",
|
||||
"removeConnectionMessage": "${displayName} verliert den Zugriff auf ${connectionLabel}. Die Verbindung bleibt für andere Profile verfügbar.",
|
||||
"deleteProfileTitle": "Profil löschen?",
|
||||
"deleteProfileMessage": "Dies entfernt ${displayName} und alle zugehörigen Verbindungen von diesem Gerät. Die zugrunde liegenden Plex-/Jellyfin-Server sind nicht betroffen.",
|
||||
"profileNameLabel": "Profilname",
|
||||
"pinProtectionLabel": "PIN-Schutz",
|
||||
"pinManagedByPlex": "PIN wird von Plex verwaltet. Auf plex.tv bearbeiten.",
|
||||
"noPinSetEditOnPlex": "Keine PIN festgelegt. Um eine zu verlangen, bearbeite den Home-Benutzer auf plex.tv.",
|
||||
"setPin": "PIN festlegen",
|
||||
"connectionsLabel": "Verbindungen",
|
||||
"add": "Hinzufügen",
|
||||
"deleteProfileButton": "Profil löschen",
|
||||
"noConnectionsHint": "Keine Verbindungen — füge eine hinzu, um dieses Profil zu nutzen.",
|
||||
"plexHomeAccount": "Plex Home-Konto",
|
||||
"connectionDefault": "Standard",
|
||||
"makeDefault": "Als Standard",
|
||||
"removeConnection": "Entfernen",
|
||||
"borrowAddTo": "Zu ${displayName} hinzufügen",
|
||||
"borrowExplain": "Eine Verbindung von einem anderen Profil ausleihen. PIN-geschützte Quellprofile fordern die PIN vor der Freigabe an.",
|
||||
"borrowEmpty": "Noch nichts zum Ausleihen.",
|
||||
"borrowEmptySubtitle": "Verbinde zuerst ein Plex-Konto oder einen Jellyfin-Server mit einem anderen Profil und komme dann hierher zurück.",
|
||||
"newProfile": "Neues Profil",
|
||||
"profileNameHint": "z. B. Gäste, Kinder, Wohnzimmer",
|
||||
"pinProtectionOptional": "PIN-Schutz (optional)",
|
||||
"pinExplain": "4-stellige PIN erforderlich, um zu diesem Profil zu wechseln. Weiche Barriere — wer App-Daten löschen kann, kann sie umgehen.",
|
||||
"continueButton": "Weiter",
|
||||
"pinsDontMatch": "PINs stimmen nicht überein"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Verbindungen",
|
||||
"addConnection": "Verbindung hinzufügen",
|
||||
"addConnectionSubtitleNoProfile": "Mit Plex anmelden oder Jellyfin-Server verbinden",
|
||||
"addConnectionSubtitleScoped": "Zu ${displayName} hinzufügen — Plex-Konto, Jellyfin-Server oder von einem anderen Profil ausleihen",
|
||||
"sessionExpiredOne": "Sitzung für ${name} abgelaufen",
|
||||
"sessionExpiredMany": "Sitzungen für ${count} Server abgelaufen",
|
||||
"signInAgain": "Erneut anmelden"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Entdecken",
|
||||
"switchProfile": "Profil wechseln",
|
||||
"noContentAvailable": "Kein Inhalt verfügbar",
|
||||
"addMediaToLibraries": "Medien zur Mediathek hinzufügen",
|
||||
"continueWatching": "Weiterschauen",
|
||||
"nextUp": "Als Nächstes",
|
||||
"recentlyAdded": "Kürzlich hinzugefügt",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Übersicht",
|
||||
"cast": "Besetzung",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Suche fehlgeschlagen: ${error}",
|
||||
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
|
||||
"connectionFailed": "Verbindung zum Plex-Server fehlgeschlagen",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Fehler beim Laden von ${context}: ${error}",
|
||||
"noClientAvailable": "Kein Client verfügbar",
|
||||
"authenticationFailed": "Authentifizierung fehlgeschlagen: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Bitte Token eingeben",
|
||||
"invalidToken": "Ungültiges Token",
|
||||
"failedToVerifyToken": "Token-Verifizierung fehlgeschlagen: ${error}",
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen"
|
||||
"failedToSwitchProfile": "Profilwechsel zu ${displayName} fehlgeschlagen",
|
||||
"failedToDeleteProfile": "Löschen von ${displayName} fehlgeschlagen",
|
||||
"failedToRate": "Bewertung konnte nicht aktualisiert werden"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Mediatheken",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Staffeln",
|
||||
"episodes": "Episoden",
|
||||
"folders": "Ordner"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "Jahr",
|
||||
"contentRating": "Altersfreigabe",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titel",
|
||||
"dateAdded": "Hinzugefügt am",
|
||||
"releaseDate": "Erscheinungsdatum",
|
||||
"rating": "Bewertung",
|
||||
"lastPlayed": "Zuletzt abgespielt",
|
||||
"playCount": "Wiedergaben",
|
||||
"random": "Zufällig",
|
||||
"dateShared": "Datum geteilt",
|
||||
"latestEpisodeAirDate": "Letztes Folgenausstrahlungsdatum"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"openSourceLicenses": "Open-Source-Lizenzen",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "Ein schöner Plex-Client für Flutter",
|
||||
"appDescription": "Ein schöner Plex- und Jellyfin-Client für Flutter",
|
||||
"viewLicensesDescription": "Lizenzen von Drittanbieter-Bibliotheken anzeigen"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Warte auf andere zum Laden...",
|
||||
"recentRooms": "Letzte Räume",
|
||||
"renameRoom": "Raum umbenennen",
|
||||
"removeRoom": "Entfernen"
|
||||
"removeRoom": "Entfernen",
|
||||
"guestSwitchUnavailable": "Wechsel fehlgeschlagen — Server nicht für Synchronisierung verfügbar",
|
||||
"guestSwitchFailed": "Wechsel fehlgeschlagen — Inhalt auf diesem Server nicht gefunden"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Synchronisierungsfilter",
|
||||
"syncAllItems": "Alle Einträge synchronisieren",
|
||||
"syncUnwatchedItems": "Ungesehene Einträge synchronisieren",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Verfügbar",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Anmeldung erforderlich",
|
||||
"syncRuleNotAvailableForProfile": "Für aktuelles Profil nicht verfügbar",
|
||||
"syncRuleUnknownServer": "Unbekannter Server",
|
||||
"syncRuleListCreated": "Sync-Regel erstellt"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliotheken",
|
||||
"noLibraries": "Keine Bibliotheken verfügbar"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin-Server hinzufügen",
|
||||
"jellyfinUrlIntro": "Gib die URL deines Jellyfin-Servers ein — z. B. `https://jellyfin.example.com`. Anmelden kannst du dich danach.",
|
||||
"serverUrl": "Server-URL",
|
||||
"findServer": "Server finden",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"signIn": "Anmelden",
|
||||
"change": "Ändern",
|
||||
"required": "Erforderlich",
|
||||
"couldNotReachServer": "Server nicht erreichbar: ${error}",
|
||||
"signInFailed": "Anmeldung fehlgeschlagen: ${error}",
|
||||
"quickConnectFailed": "Quick Connect fehlgeschlagen: ${error}",
|
||||
"addPlexTitle": "Mit Plex anmelden",
|
||||
"plexAuthIntro": "Wähle, wie du dich bei Plex anmelden möchtest. Der Browser-Ablauf öffnet plex.tv, wo du die Verbindung bestätigst; die QR-Option eignet sich für TV / Remote-Geräte.",
|
||||
"plexQRPrompt": "Scanne diesen QR-Code zum Anmelden.",
|
||||
"waitingForPlexConfirmation": "Warte auf Bestätigung durch plex.tv…",
|
||||
"pinExpired": "PIN ist vor der Anmeldung abgelaufen. Bitte erneut versuchen.",
|
||||
"duplicatePlexAccount": "Dieses Gerät ist bereits bei einem Plex-Konto angemeldet. Melde dich in den Einstellungen ab, um das Konto zu wechseln.",
|
||||
"failedToRegisterAccount": "Konto konnte nicht registriert werden: ${error}",
|
||||
"enterJellyfinUrlError": "Gib die URL deines Jellyfin-Servers ein",
|
||||
"addConnectionTitle": "Verbindung hinzufügen",
|
||||
"addConnectionTitleScoped": "Zu ${name} hinzufügen",
|
||||
"addConnectionIntroGlobal": "Füge einen weiteren Medienserver hinzu. Du kannst Plex-Konten und Jellyfin-Server kombinieren — Inhalte aller verbundenen Backends erscheinen gemeinsam auf dem Startbildschirm.",
|
||||
"addConnectionIntroScoped": "Füge einen neuen Server hinzu oder leihe einen von einem anderen Profil aus.",
|
||||
"signInWithPlexCard": "Mit Plex anmelden",
|
||||
"signInWithPlexCardSubtitle": "Autorisiere dieses Gerät mit deinem Plex-Konto. Mit dem Konto geteilte Server kommen automatisch mit.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorisiere ein neues Plex-Konto. Dessen Home-Benutzer erscheinen als Profile.",
|
||||
"connectToJellyfinCard": "Mit Jellyfin verbinden",
|
||||
"connectToJellyfinCardSubtitle": "Gib die URL deines Jellyfin-Servers ein und melde dich mit Benutzername + Passwort an (Quick Connect kommt bald).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Bei einem Jellyfin-Server anmelden. Wird mit ${name} verknüpft.",
|
||||
"borrowFromAnotherProfile": "Von einem anderen Profil ausleihen",
|
||||
"borrowFromAnotherProfileSubtitle": "Verwende eine Verbindung wieder, die bereits einem anderen Profil zugeordnet ist. Bei PIN-geschützten Quellprofilen wird die PIN abgefragt."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInWithPlex": "Sign in with Plex",
|
||||
"showQRCode": "Show QR Code",
|
||||
"authenticate": "Authenticate",
|
||||
"authenticationTimeout": "Authentication timed out. Please try again.",
|
||||
"scanQRToSignIn": "Scan this QR code to sign in",
|
||||
"waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser.",
|
||||
"useBrowser": "Use browser"
|
||||
"useBrowser": "Use browser",
|
||||
"or": "or",
|
||||
"connectToJellyfin": "Connect to Jellyfin",
|
||||
"useQuickConnect": "Use Quick Connect",
|
||||
"quickConnectCode": "Quick Connect code",
|
||||
"quickConnectInstructions": "Open your Jellyfin server in a web browser, sign in, and choose Quick Connect from the user menu. Enter this code to approve sign-in.",
|
||||
"quickConnectWaiting": "Waiting for approval…",
|
||||
"quickConnectCancel": "Cancel",
|
||||
"quickConnectExpired": "Quick Connect code expired before approval. Please try again."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Grid",
|
||||
"listView": "List",
|
||||
"showHeroSection": "Show Hero Section",
|
||||
"useGlobalHubs": "Use Plex Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official Plex client. When off, shows per-library recommendations instead.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Show Server Name on Hubs",
|
||||
"showServerNameOnHubsDescription": "Always display the server name in hub titles. When off, only shows for duplicate hub names.",
|
||||
"groupLibrariesByServer": "Group Libraries by Server",
|
||||
"groupLibrariesByServerDescription": "Show a header for each Plex server in the sidebar when you're connected to multiple servers.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Always Keep Sidebar Open",
|
||||
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
|
||||
"showUnwatchedCount": "Show Unwatched Count",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Music playback is not yet supported",
|
||||
"noDescriptionAvailable": "No description available",
|
||||
"noProfilesAvailable": "No profiles available",
|
||||
"contactAdminForProfiles": "Contact your Plex administrator to add profiles",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Unable to determine library section for this item",
|
||||
"logsCleared": "Logs cleared",
|
||||
"logsCopied": "Logs copied to clipboard",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Confirm Action"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Add Plezy profile",
|
||||
"switchingProfile": "Switching profile…",
|
||||
"deleteThisProfileTitle": "Delete this profile?",
|
||||
"deleteThisProfileMessage": "${displayName} will be removed. Connections themselves are not affected.",
|
||||
"active": "Active",
|
||||
"manage": "Manage",
|
||||
"delete": "Delete",
|
||||
"signOut": "Sign out",
|
||||
"signOutPlexTitle": "Sign out of Plex?",
|
||||
"signOutPlexMessage": "${displayName} and every Plex Home user on this account will be removed from this device. You can sign back in any time.",
|
||||
"signedOutPlex": "Signed out of Plex.",
|
||||
"signOutFailed": "Sign out failed.",
|
||||
"sectionTitle": "Profiles",
|
||||
"summarySingle": "Add profiles to mix managed users and local identities",
|
||||
"summaryMultipleWithActive": "${count} profiles · active: ${activeName}",
|
||||
"summaryMultiple": "${count} profiles",
|
||||
"removeConnectionTitle": "Remove connection?",
|
||||
"removeConnectionMessage": "${displayName} will lose access to ${connectionLabel}. The connection itself stays available to other profiles.",
|
||||
"deleteProfileTitle": "Delete profile?",
|
||||
"deleteProfileMessage": "This removes ${displayName} and all its connections from this device. The underlying Plex/Jellyfin servers aren't affected.",
|
||||
"profileNameLabel": "Profile name",
|
||||
"pinProtectionLabel": "PIN protection",
|
||||
"pinManagedByPlex": "PIN managed by Plex. Edit on plex.tv.",
|
||||
"noPinSetEditOnPlex": "No PIN set. To require one, edit the home user on plex.tv.",
|
||||
"setPin": "Set PIN",
|
||||
"connectionsLabel": "Connections",
|
||||
"add": "Add",
|
||||
"deleteProfileButton": "Delete profile",
|
||||
"noConnectionsHint": "No connections — add one to use this profile.",
|
||||
"plexHomeAccount": "Plex Home account",
|
||||
"connectionDefault": "Default",
|
||||
"makeDefault": "Make default",
|
||||
"removeConnection": "Remove",
|
||||
"borrowAddTo": "Add to ${displayName}",
|
||||
"borrowExplain": "Borrow a connection from another profile. PIN-protected source profiles ask for the PIN before sharing.",
|
||||
"borrowEmpty": "Nothing to borrow yet.",
|
||||
"borrowEmptySubtitle": "Connect a Plex account or Jellyfin server to another profile first, then come back here.",
|
||||
"newProfile": "New profile",
|
||||
"profileNameHint": "e.g. Guests, Kids, Family Room",
|
||||
"pinProtectionOptional": "PIN protection (optional)",
|
||||
"pinExplain": "4-digit PIN required to switch into this profile. Soft barrier — anyone who can clear app data can bypass it.",
|
||||
"continueButton": "Continue",
|
||||
"pinsDontMatch": "PINs don't match"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Connections",
|
||||
"addConnection": "Add connection",
|
||||
"addConnectionSubtitleNoProfile": "Sign in with Plex or connect a Jellyfin server",
|
||||
"addConnectionSubtitleScoped": "Add to ${displayName} — Plex account, Jellyfin server, or borrow from another profile",
|
||||
"sessionExpiredOne": "Session expired for ${name}",
|
||||
"sessionExpiredMany": "Session expired for ${count} servers",
|
||||
"signInAgain": "Sign in again"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Discover",
|
||||
"switchProfile": "Switch Profile",
|
||||
"noContentAvailable": "No content available",
|
||||
"addMediaToLibraries": "Add some media to your libraries",
|
||||
"continueWatching": "Continue Watching",
|
||||
"nextUp": "Next Up",
|
||||
"recentlyAdded": "Recently Added",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Overview",
|
||||
"cast": "Cast",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Search failed: ${error}",
|
||||
"connectionTimeout": "Connection timeout while loading ${context}",
|
||||
"connectionFailed": "Unable to connect to Plex server",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Failed to load ${context}: ${error}",
|
||||
"noClientAvailable": "No client available",
|
||||
"authenticationFailed": "Authentication failed: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Please enter a token",
|
||||
"invalidToken": "Invalid token",
|
||||
"failedToVerifyToken": "Failed to verify token: ${error}",
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}"
|
||||
"failedToSwitchProfile": "Failed to switch to ${displayName}",
|
||||
"failedToDeleteProfile": "Failed to delete ${displayName}",
|
||||
"failedToRate": "Couldn't update rating"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Libraries",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Seasons",
|
||||
"episodes": "Episodes",
|
||||
"folders": "Folders"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "Year",
|
||||
"contentRating": "Content Rating",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Title",
|
||||
"dateAdded": "Date Added",
|
||||
"releaseDate": "Release Date",
|
||||
"rating": "Rating",
|
||||
"lastPlayed": "Last Played",
|
||||
"playCount": "Play Count",
|
||||
"random": "Random",
|
||||
"dateShared": "Date Shared",
|
||||
"latestEpisodeAirDate": "Latest Episode Air Date"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"openSourceLicenses": "Open Source Licenses",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "A beautiful Plex client for Flutter",
|
||||
"appDescription": "A beautiful Plex and Jellyfin client for Flutter",
|
||||
"viewLicensesDescription": "View licenses of third-party libraries"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Waiting for others to load...",
|
||||
"recentRooms": "Recent Rooms",
|
||||
"renameRoom": "Rename Room",
|
||||
"removeRoom": "Remove"
|
||||
"removeRoom": "Remove",
|
||||
"guestSwitchUnavailable": "Couldn't switch — server unavailable for sync",
|
||||
"guestSwitchFailed": "Couldn't switch — content not found on this server"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Sync filter",
|
||||
"syncAllItems": "Syncing all items",
|
||||
"syncUnwatchedItems": "Syncing unwatched items",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Available",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Sign in required",
|
||||
"syncRuleNotAvailableForProfile": "Not available for current profile",
|
||||
"syncRuleUnknownServer": "Unknown server",
|
||||
"syncRuleListCreated": "Sync rule created"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Libraries",
|
||||
"noLibraries": "No libraries available"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Add Jellyfin server",
|
||||
"jellyfinUrlIntro": "Enter your Jellyfin server URL — e.g. `https://jellyfin.example.com`. You can sign in afterwards.",
|
||||
"serverUrl": "Server URL",
|
||||
"findServer": "Find server",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"change": "Change",
|
||||
"required": "Required",
|
||||
"couldNotReachServer": "Could not reach the server: ${error}",
|
||||
"signInFailed": "Sign-in failed: ${error}",
|
||||
"quickConnectFailed": "Quick Connect failed: ${error}",
|
||||
"addPlexTitle": "Sign in with Plex",
|
||||
"plexAuthIntro": "Choose how to sign in to Plex. The browser flow opens plex.tv where you confirm the connection; the QR option is handy for TV / remote devices.",
|
||||
"plexQRPrompt": "Scan this QR code to sign in.",
|
||||
"waitingForPlexConfirmation": "Waiting for plex.tv to confirm your sign-in…",
|
||||
"pinExpired": "PIN expired before sign-in. Please try again.",
|
||||
"duplicatePlexAccount": "This device is already signed in to a Plex account. Sign out from settings to switch accounts.",
|
||||
"failedToRegisterAccount": "Failed to register account: ${error}",
|
||||
"enterJellyfinUrlError": "Enter your Jellyfin server URL",
|
||||
"addConnectionTitle": "Add connection",
|
||||
"addConnectionTitleScoped": "Add to ${name}",
|
||||
"addConnectionIntroGlobal": "Add another media server. You can mix Plex accounts and Jellyfin servers — items from every connected backend appear together on the home screen.",
|
||||
"addConnectionIntroScoped": "Add a new server, or borrow one from another profile.",
|
||||
"signInWithPlexCard": "Sign in with Plex",
|
||||
"signInWithPlexCardSubtitle": "Authorize this device against your Plex account. Servers shared with the account come along automatically.",
|
||||
"signInWithPlexCardSubtitleScoped": "Authorize a new Plex account. Its Home users appear as profiles.",
|
||||
"connectToJellyfinCard": "Connect to Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Enter your Jellyfin server URL and sign in with username + password (Quick Connect coming soon).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Sign in to a Jellyfin server. Binds to ${name}.",
|
||||
"borrowFromAnotherProfile": "Borrow from another profile",
|
||||
"borrowFromAnotherProfileSubtitle": "Reuse a connection that's already attached to a different profile. PIN-protected source profiles ask for the PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Iniciar sesión",
|
||||
"signInWithPlex": "Inicia sesión con Plex",
|
||||
"showQRCode": "Mostrar código QR",
|
||||
"authenticate": "Autenticar",
|
||||
"authenticationTimeout": "Tiempo de autenticación agotado. Por favor, intenta de nuevo.",
|
||||
"scanQRToSignIn": "Escanea este código QR para iniciar sesión",
|
||||
"waitingForAuth": "Esperando autenticación...\nPor favor completa el inicio de sesión en tu navegador.",
|
||||
"useBrowser": "Usar navegador"
|
||||
"useBrowser": "Usar navegador",
|
||||
"or": "o",
|
||||
"connectToJellyfin": "Conectar a Jellyfin",
|
||||
"useQuickConnect": "Usar Quick Connect",
|
||||
"quickConnectCode": "Código de Quick Connect",
|
||||
"quickConnectInstructions": "Abre tu servidor Jellyfin en un navegador web, inicia sesión y selecciona Quick Connect en el menú de usuario. Introduce este código para aprobar el inicio de sesión.",
|
||||
"quickConnectWaiting": "Esperando aprobación…",
|
||||
"quickConnectCancel": "Cancelar",
|
||||
"quickConnectExpired": "El código de Quick Connect caducó antes de ser aprobado. Inténtalo de nuevo."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancelar",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Cuadrícula",
|
||||
"listView": "Lista",
|
||||
"showHeroSection": "Mostrar Sección Destacada",
|
||||
"useGlobalHubs": "Usar Diseño de Inicio de Plex",
|
||||
"useGlobalHubsDescription": "Mostrar los hubs de la página de inicio como el cliente oficial de Plex. Cuando está desactivado, muestra recomendaciones por biblioteca en su lugar.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Mostrar Nombre del Servidor en los Hubs",
|
||||
"showServerNameOnHubsDescription": "Mostrar siempre el nombre del servidor en los títulos de los hubs. Cuando está desactivado, solo se muestra para nombres de hubs duplicados.",
|
||||
"groupLibrariesByServer": "Agrupar bibliotecas por servidor",
|
||||
"groupLibrariesByServerDescription": "Muestra un encabezado para cada servidor Plex en la barra lateral cuando estás conectado a varios servidores.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Mantener siempre la barra lateral abierta",
|
||||
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
|
||||
"showUnwatchedCount": "Mostrar conteo de no vistos",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "La reproducción de música aún no está soportada",
|
||||
"noDescriptionAvailable": "No hay descripción disponible",
|
||||
"noProfilesAvailable": "No hay perfiles disponibles",
|
||||
"contactAdminForProfiles": "Contacta con tu administrador de Plex para añadir perfiles",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "No se puede determinar la sección de biblioteca para este elemento",
|
||||
"logsCleared": "Logs borrados",
|
||||
"logsCopied": "Logs copiados al portapapeles",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Confirmar Acción"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Añadir perfil de Plezy",
|
||||
"switchingProfile": "Cambiando de perfil…",
|
||||
"deleteThisProfileTitle": "¿Eliminar este perfil?",
|
||||
"deleteThisProfileMessage": "${displayName} se eliminará. Las conexiones no se verán afectadas.",
|
||||
"active": "Activo",
|
||||
"manage": "Administrar",
|
||||
"delete": "Eliminar",
|
||||
"signOut": "Cerrar sesión",
|
||||
"signOutPlexTitle": "¿Cerrar sesión de Plex?",
|
||||
"signOutPlexMessage": "${displayName} y todos los usuarios de Plex Home de esta cuenta se eliminarán de este dispositivo. Puedes volver a iniciar sesión en cualquier momento.",
|
||||
"signedOutPlex": "Sesión de Plex cerrada.",
|
||||
"signOutFailed": "Error al cerrar sesión.",
|
||||
"sectionTitle": "Perfiles",
|
||||
"summarySingle": "Añade perfiles para mezclar usuarios gestionados e identidades locales",
|
||||
"summaryMultipleWithActive": "${count} perfiles · activo: ${activeName}",
|
||||
"summaryMultiple": "${count} perfiles",
|
||||
"removeConnectionTitle": "¿Eliminar conexión?",
|
||||
"removeConnectionMessage": "${displayName} perderá acceso a ${connectionLabel}. La conexión seguirá disponible para otros perfiles.",
|
||||
"deleteProfileTitle": "¿Eliminar perfil?",
|
||||
"deleteProfileMessage": "Esto elimina ${displayName} y todas sus conexiones de este dispositivo. Los servidores Plex/Jellyfin subyacentes no se ven afectados.",
|
||||
"profileNameLabel": "Nombre del perfil",
|
||||
"pinProtectionLabel": "Protección con PIN",
|
||||
"pinManagedByPlex": "PIN gestionado por Plex. Edita en plex.tv.",
|
||||
"noPinSetEditOnPlex": "Sin PIN establecido. Para requerir uno, edita el usuario Home en plex.tv.",
|
||||
"setPin": "Establecer PIN",
|
||||
"connectionsLabel": "Conexiones",
|
||||
"add": "Añadir",
|
||||
"deleteProfileButton": "Eliminar perfil",
|
||||
"noConnectionsHint": "Sin conexiones — añade una para usar este perfil.",
|
||||
"plexHomeAccount": "Cuenta Plex Home",
|
||||
"connectionDefault": "Predeterminada",
|
||||
"makeDefault": "Establecer como predeterminada",
|
||||
"removeConnection": "Eliminar",
|
||||
"borrowAddTo": "Añadir a ${displayName}",
|
||||
"borrowExplain": "Toma prestada una conexión de otro perfil. Los perfiles de origen protegidos con PIN piden el PIN antes de compartir.",
|
||||
"borrowEmpty": "Nada para tomar prestado todavía.",
|
||||
"borrowEmptySubtitle": "Conecta primero una cuenta Plex o un servidor Jellyfin a otro perfil y vuelve aquí.",
|
||||
"newProfile": "Nuevo perfil",
|
||||
"profileNameHint": "p. ej. Invitados, Niños, Sala familiar",
|
||||
"pinProtectionOptional": "Protección con PIN (opcional)",
|
||||
"pinExplain": "Se requiere un PIN de 4 dígitos para cambiar a este perfil. Barrera blanda — cualquiera que pueda borrar los datos de la app puede saltarla.",
|
||||
"continueButton": "Continuar",
|
||||
"pinsDontMatch": "Los PIN no coinciden"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Conexiones",
|
||||
"addConnection": "Añadir conexión",
|
||||
"addConnectionSubtitleNoProfile": "Inicia sesión con Plex o conecta un servidor de Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Añadir a ${displayName} — cuenta de Plex, servidor de Jellyfin o tomar prestado de otro perfil",
|
||||
"sessionExpiredOne": "Sesión caducada para ${name}",
|
||||
"sessionExpiredMany": "Sesión caducada para ${count} servidores",
|
||||
"signInAgain": "Iniciar sesión de nuevo"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descubrir",
|
||||
"switchProfile": "Cambiar Perfil",
|
||||
"noContentAvailable": "No hay contenido disponible",
|
||||
"addMediaToLibraries": "Añade contenido a tus bibliotecas",
|
||||
"continueWatching": "Seguir Viendo",
|
||||
"nextUp": "A continuación",
|
||||
"recentlyAdded": "Añadido recientemente",
|
||||
"playEpisode": "T${season}E${episode}",
|
||||
"overview": "Resumen",
|
||||
"cast": "Reparto",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Error en la búsqueda: ${error}",
|
||||
"connectionTimeout": "Tiempo de conexión agotado al cargar ${context}",
|
||||
"connectionFailed": "No se pudo conectar con el servidor Plex",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Error al cargar ${context}: ${error}",
|
||||
"noClientAvailable": "No hay cliente disponible",
|
||||
"authenticationFailed": "Error de autenticación: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Por favor, introduce un token",
|
||||
"invalidToken": "Token no válido",
|
||||
"failedToVerifyToken": "Error al verificar el token: ${error}",
|
||||
"failedToSwitchProfile": "Error al cambiar al perfil ${displayName}"
|
||||
"failedToSwitchProfile": "Error al cambiar al perfil ${displayName}",
|
||||
"failedToDeleteProfile": "Error al eliminar ${displayName}",
|
||||
"failedToRate": "No se pudo actualizar la calificación"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotecas",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Temporadas",
|
||||
"episodes": "Episodios",
|
||||
"folders": "Carpetas"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Género",
|
||||
"year": "Año",
|
||||
"contentRating": "Clasificación",
|
||||
"tag": "Etiqueta"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Título",
|
||||
"dateAdded": "Fecha de adición",
|
||||
"releaseDate": "Fecha de estreno",
|
||||
"rating": "Valoración",
|
||||
"lastPlayed": "Última reproducción",
|
||||
"playCount": "Reproducciones",
|
||||
"random": "Aleatorio",
|
||||
"dateShared": "Fecha de compartición",
|
||||
"latestEpisodeAirDate": "Última fecha de emisión del episodio"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"openSourceLicenses": "Licencias de Código Abierto",
|
||||
"versionLabel": "Versión ${version}",
|
||||
"appDescription": "Un cliente de Plex para Flutter",
|
||||
"appDescription": "Un cliente de Plex y Jellyfin para Flutter",
|
||||
"viewLicensesDescription": "Ver licencias de librerías de terceros"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Esperando a que otros carguen...",
|
||||
"recentRooms": "Salas recientes",
|
||||
"renameRoom": "Renombrar sala",
|
||||
"removeRoom": "Eliminar"
|
||||
"removeRoom": "Eliminar",
|
||||
"guestSwitchUnavailable": "No se pudo cambiar — servidor no disponible para sincronización",
|
||||
"guestSwitchFailed": "No se pudo cambiar — contenido no encontrado en este servidor"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Descargas",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Filtro de sincronización",
|
||||
"syncAllItems": "Sincronizando todos los elementos",
|
||||
"syncUnwatchedItems": "Sincronizando elementos no vistos",
|
||||
"syncRuleServerContext": "Servidor: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Disponible",
|
||||
"syncRuleOffline": "Sin conexión",
|
||||
"syncRuleSignInRequired": "Se requiere iniciar sesión",
|
||||
"syncRuleNotAvailableForProfile": "No disponible para el perfil actual",
|
||||
"syncRuleUnknownServer": "Servidor desconocido",
|
||||
"syncRuleListCreated": "Regla de sincronización creada"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliotecas",
|
||||
"noLibraries": "No hay bibliotecas disponibles"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Añadir servidor Jellyfin",
|
||||
"jellyfinUrlIntro": "Introduce la URL de tu servidor Jellyfin — p. ej. `https://jellyfin.example.com`. Podrás iniciar sesión después.",
|
||||
"serverUrl": "URL del servidor",
|
||||
"findServer": "Buscar servidor",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"signIn": "Iniciar sesión",
|
||||
"change": "Cambiar",
|
||||
"required": "Obligatorio",
|
||||
"couldNotReachServer": "No se pudo conectar con el servidor: ${error}",
|
||||
"signInFailed": "Error al iniciar sesión: ${error}",
|
||||
"quickConnectFailed": "Quick Connect ha fallado: ${error}",
|
||||
"addPlexTitle": "Iniciar sesión con Plex",
|
||||
"plexAuthIntro": "Elige cómo iniciar sesión en Plex. El flujo del navegador abre plex.tv, donde confirmas la conexión; la opción QR es práctica para TV o dispositivos remotos.",
|
||||
"plexQRPrompt": "Escanea este código QR para iniciar sesión.",
|
||||
"waitingForPlexConfirmation": "Esperando que plex.tv confirme tu inicio de sesión…",
|
||||
"pinExpired": "El PIN caducó antes de iniciar sesión. Inténtalo de nuevo.",
|
||||
"duplicatePlexAccount": "Este dispositivo ya está conectado a una cuenta de Plex. Cierra sesión desde los ajustes para cambiar de cuenta.",
|
||||
"failedToRegisterAccount": "No se pudo registrar la cuenta: ${error}",
|
||||
"enterJellyfinUrlError": "Introduce la URL de tu servidor Jellyfin",
|
||||
"addConnectionTitle": "Añadir conexión",
|
||||
"addConnectionTitleScoped": "Añadir a ${name}",
|
||||
"addConnectionIntroGlobal": "Añade otro servidor multimedia. Puedes combinar cuentas de Plex y servidores Jellyfin — el contenido de cada backend conectado aparece junto en la pantalla de inicio.",
|
||||
"addConnectionIntroScoped": "Añade un servidor nuevo o toma prestado uno de otro perfil.",
|
||||
"signInWithPlexCard": "Iniciar sesión con Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriza este dispositivo en tu cuenta de Plex. Los servidores compartidos con la cuenta se añaden automáticamente.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriza una nueva cuenta de Plex. Sus usuarios Home aparecen como perfiles.",
|
||||
"connectToJellyfinCard": "Conectar a Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Introduce la URL de tu servidor Jellyfin e inicia sesión con usuario + contraseña (Quick Connect próximamente).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Inicia sesión en un servidor Jellyfin. Se vincula a ${name}.",
|
||||
"borrowFromAnotherProfile": "Tomar prestado de otro perfil",
|
||||
"borrowFromAnotherProfileSubtitle": "Reutiliza una conexión ya asociada a otro perfil. Los perfiles de origen protegidos con PIN piden el PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Se connecter",
|
||||
"signInWithPlex": "S'inscrire avec Plex",
|
||||
"showQRCode": "Afficher le QR Code",
|
||||
"authenticate": "S'authentifier",
|
||||
"authenticationTimeout": "Délai d'authentification expiré. Veuillez réessayer.",
|
||||
"scanQRToSignIn": "Scannez ce QR code pour vous connecter",
|
||||
"waitingForAuth": "En attente d'authentification...\nVeuillez vous connecter dans votre navigateur.",
|
||||
"useBrowser": "Utiliser le navigateur"
|
||||
"useBrowser": "Utiliser le navigateur",
|
||||
"or": "ou",
|
||||
"connectToJellyfin": "Se connecter à Jellyfin",
|
||||
"useQuickConnect": "Utiliser Quick Connect",
|
||||
"quickConnectCode": "Code Quick Connect",
|
||||
"quickConnectInstructions": "Ouvrez votre serveur Jellyfin dans un navigateur, connectez-vous et choisissez Quick Connect dans le menu utilisateur. Saisissez ce code pour valider la connexion.",
|
||||
"quickConnectWaiting": "En attente d'approbation…",
|
||||
"quickConnectCancel": "Annuler",
|
||||
"quickConnectExpired": "Le code Quick Connect a expiré avant l'approbation. Veuillez réessayer."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Grille",
|
||||
"listView": "Liste",
|
||||
"showHeroSection": "Afficher la section Hero",
|
||||
"useGlobalHubs": "Utiliser la disposition Plex Home",
|
||||
"useGlobalHubsDescription": "Afficher les hubs de la page d'accueil comme le client Plex officiel. Lorsque cette option est désactivée, affiche à la place les recommandations par bibliothèque.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Afficher le nom du serveur sur les hubs",
|
||||
"showServerNameOnHubsDescription": "Toujours afficher le nom du serveur dans les titres des hubs. Lorsque cette option est désactivée, seuls les noms de hubs en double s'affichent.",
|
||||
"groupLibrariesByServer": "Grouper les bibliothèques par serveur",
|
||||
"groupLibrariesByServerDescription": "Affiche un en-tête pour chaque serveur Plex dans la barre latérale lorsque vous êtes connecté à plusieurs serveurs.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Toujours garder la barre latérale ouverte",
|
||||
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
|
||||
"showUnwatchedCount": "Afficher le nombre non visionné",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "La lecture de musique n'est pas encore prise en charge",
|
||||
"noDescriptionAvailable": "Aucune description disponible",
|
||||
"noProfilesAvailable": "Aucun profil disponible",
|
||||
"contactAdminForProfiles": "Contactez votre administrateur Plex pour ajouter des profils",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Impossible de déterminer la section de la bibliothèque pour cet élément",
|
||||
"logsCleared": "Logs effacés",
|
||||
"logsCopied": "Logs copiés dans le presse-papier",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Confirmer l'action"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Ajouter un profil Plezy",
|
||||
"switchingProfile": "Changement de profil…",
|
||||
"deleteThisProfileTitle": "Supprimer ce profil ?",
|
||||
"deleteThisProfileMessage": "${displayName} sera supprimé. Les connexions ne seront pas affectées.",
|
||||
"active": "Actif",
|
||||
"manage": "Gérer",
|
||||
"delete": "Supprimer",
|
||||
"signOut": "Se déconnecter",
|
||||
"signOutPlexTitle": "Se déconnecter de Plex ?",
|
||||
"signOutPlexMessage": "${displayName} et tous les utilisateurs Plex Home de ce compte seront supprimés de cet appareil. Vous pouvez vous reconnecter à tout moment.",
|
||||
"signedOutPlex": "Déconnecté de Plex.",
|
||||
"signOutFailed": "Échec de la déconnexion.",
|
||||
"sectionTitle": "Profils",
|
||||
"summarySingle": "Ajoutez des profils pour mélanger utilisateurs gérés et identités locales",
|
||||
"summaryMultipleWithActive": "${count} profils · actif : ${activeName}",
|
||||
"summaryMultiple": "${count} profils",
|
||||
"removeConnectionTitle": "Retirer la connexion ?",
|
||||
"removeConnectionMessage": "${displayName} perdra l'accès à ${connectionLabel}. La connexion reste disponible pour les autres profils.",
|
||||
"deleteProfileTitle": "Supprimer le profil ?",
|
||||
"deleteProfileMessage": "Cela supprime ${displayName} et toutes ses connexions de cet appareil. Les serveurs Plex/Jellyfin sous-jacents ne sont pas affectés.",
|
||||
"profileNameLabel": "Nom du profil",
|
||||
"pinProtectionLabel": "Protection par code PIN",
|
||||
"pinManagedByPlex": "PIN géré par Plex. Modifier sur plex.tv.",
|
||||
"noPinSetEditOnPlex": "Aucun PIN défini. Pour en exiger un, modifiez l'utilisateur Home sur plex.tv.",
|
||||
"setPin": "Définir un PIN",
|
||||
"connectionsLabel": "Connexions",
|
||||
"add": "Ajouter",
|
||||
"deleteProfileButton": "Supprimer le profil",
|
||||
"noConnectionsHint": "Aucune connexion — ajoutez-en une pour utiliser ce profil.",
|
||||
"plexHomeAccount": "Compte Plex Home",
|
||||
"connectionDefault": "Par défaut",
|
||||
"makeDefault": "Définir par défaut",
|
||||
"removeConnection": "Retirer",
|
||||
"borrowAddTo": "Ajouter à ${displayName}",
|
||||
"borrowExplain": "Empruntez une connexion à un autre profil. Les profils sources protégés par PIN demandent le PIN avant de partager.",
|
||||
"borrowEmpty": "Rien à emprunter pour le moment.",
|
||||
"borrowEmptySubtitle": "Connectez d'abord un compte Plex ou un serveur Jellyfin à un autre profil, puis revenez ici.",
|
||||
"newProfile": "Nouveau profil",
|
||||
"profileNameHint": "ex. Invités, Enfants, Salon familial",
|
||||
"pinProtectionOptional": "Protection par PIN (optionnelle)",
|
||||
"pinExplain": "PIN à 4 chiffres requis pour basculer sur ce profil. Barrière souple — quiconque peut effacer les données de l'app peut la contourner.",
|
||||
"continueButton": "Continuer",
|
||||
"pinsDontMatch": "Les PIN ne correspondent pas"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Connexions",
|
||||
"addConnection": "Ajouter une connexion",
|
||||
"addConnectionSubtitleNoProfile": "Connectez-vous avec Plex ou connectez un serveur Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Ajouter à ${displayName} — compte Plex, serveur Jellyfin ou emprunter à un autre profil",
|
||||
"sessionExpiredOne": "Session expirée pour ${name}",
|
||||
"sessionExpiredMany": "Session expirée pour ${count} serveurs",
|
||||
"signInAgain": "Se reconnecter"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Découvrez",
|
||||
"switchProfile": "Changer de profil",
|
||||
"noContentAvailable": "Aucun contenu disponible",
|
||||
"addMediaToLibraries": "Ajoutez des médias à votre bibliothèque",
|
||||
"continueWatching": "Continuer à regarder",
|
||||
"nextUp": "À suivre",
|
||||
"recentlyAdded": "Récemment ajouté",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Aperçu",
|
||||
"cast": "Cast",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Recherche échouée: ${error}",
|
||||
"connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}",
|
||||
"connectionFailed": "Impossible de se connecter au serveur Plex",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Échec du chargement ${context}: ${error}",
|
||||
"noClientAvailable": "Aucun client disponible",
|
||||
"authenticationFailed": "Échec de l'authentification: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Veuillez saisir un token",
|
||||
"invalidToken": "Token invalide",
|
||||
"failedToVerifyToken": "Échec de la vérification du token: ${error}",
|
||||
"failedToSwitchProfile": "Impossible de changer de profil vers ${displayName}"
|
||||
"failedToSwitchProfile": "Impossible de changer de profil vers ${displayName}",
|
||||
"failedToDeleteProfile": "Impossible de supprimer ${displayName}",
|
||||
"failedToRate": "Impossible de mettre à jour la note"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliothèques",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Saisons",
|
||||
"episodes": "Épisodes",
|
||||
"folders": "Dossiers"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "Année",
|
||||
"contentRating": "Classification",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titre",
|
||||
"dateAdded": "Date d'ajout",
|
||||
"releaseDate": "Date de sortie",
|
||||
"rating": "Note",
|
||||
"lastPlayed": "Dernière lecture",
|
||||
"playCount": "Lectures",
|
||||
"random": "Aléatoire",
|
||||
"dateShared": "Date de partage",
|
||||
"latestEpisodeAirDate": "Dernière date de diffusion"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"openSourceLicenses": "Licences Open Source",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "Un magnifique client Plex pour Flutter",
|
||||
"appDescription": "Un magnifique client Plex et Jellyfin pour Flutter",
|
||||
"viewLicensesDescription": "Afficher les licences des bibliothèques tierces"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "En attente du chargement des autres...",
|
||||
"recentRooms": "Salons récents",
|
||||
"renameRoom": "Renommer le salon",
|
||||
"removeRoom": "Supprimer"
|
||||
"removeRoom": "Supprimer",
|
||||
"guestSwitchUnavailable": "Impossible de changer — serveur indisponible pour la synchronisation",
|
||||
"guestSwitchFailed": "Impossible de changer — contenu introuvable sur ce serveur"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Téléchargements",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Filtre de synchronisation",
|
||||
"syncAllItems": "Synchronisation de tous les éléments",
|
||||
"syncUnwatchedItems": "Synchronisation des éléments non vus",
|
||||
"syncRuleServerContext": "Serveur : ${server} • ${status}",
|
||||
"syncRuleAvailable": "Disponible",
|
||||
"syncRuleOffline": "Hors ligne",
|
||||
"syncRuleSignInRequired": "Connexion requise",
|
||||
"syncRuleNotAvailableForProfile": "Non disponible pour le profil actuel",
|
||||
"syncRuleUnknownServer": "Serveur inconnu",
|
||||
"syncRuleListCreated": "Règle de synchronisation créée"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliothèques",
|
||||
"noLibraries": "Aucune bibliothèque disponible"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Ajouter un serveur Jellyfin",
|
||||
"jellyfinUrlIntro": "Saisissez l'URL de votre serveur Jellyfin — p. ex. `https://jellyfin.example.com`. Vous pourrez vous connecter ensuite.",
|
||||
"serverUrl": "URL du serveur",
|
||||
"findServer": "Rechercher un serveur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
"change": "Modifier",
|
||||
"required": "Requis",
|
||||
"couldNotReachServer": "Impossible de joindre le serveur : ${error}",
|
||||
"signInFailed": "Échec de la connexion : ${error}",
|
||||
"quickConnectFailed": "Échec de Quick Connect : ${error}",
|
||||
"addPlexTitle": "Se connecter avec Plex",
|
||||
"plexAuthIntro": "Choisissez comment vous connecter à Plex. Le flux navigateur ouvre plex.tv où vous confirmez la connexion ; l'option QR est pratique pour la TV ou les appareils distants.",
|
||||
"plexQRPrompt": "Scannez ce QR code pour vous connecter.",
|
||||
"waitingForPlexConfirmation": "En attente de la confirmation de plex.tv…",
|
||||
"pinExpired": "Le PIN a expiré avant la connexion. Veuillez réessayer.",
|
||||
"duplicatePlexAccount": "Cet appareil est déjà connecté à un compte Plex. Déconnectez-vous depuis les paramètres pour changer de compte.",
|
||||
"failedToRegisterAccount": "Échec de l'enregistrement du compte : ${error}",
|
||||
"enterJellyfinUrlError": "Saisissez l'URL de votre serveur Jellyfin",
|
||||
"addConnectionTitle": "Ajouter une connexion",
|
||||
"addConnectionTitleScoped": "Ajouter à ${name}",
|
||||
"addConnectionIntroGlobal": "Ajoutez un autre serveur média. Vous pouvez mélanger comptes Plex et serveurs Jellyfin — les contenus de tous les backends connectés apparaissent ensemble sur l'écran d'accueil.",
|
||||
"addConnectionIntroScoped": "Ajoutez un nouveau serveur, ou empruntez-en un à un autre profil.",
|
||||
"signInWithPlexCard": "Se connecter avec Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorisez cet appareil avec votre compte Plex. Les serveurs partagés avec le compte suivent automatiquement.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorisez un nouveau compte Plex. Ses utilisateurs Home apparaissent comme profils.",
|
||||
"connectToJellyfinCard": "Se connecter à Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Saisissez l'URL de votre serveur Jellyfin et connectez-vous avec nom d'utilisateur + mot de passe (Quick Connect bientôt disponible).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Connectez-vous à un serveur Jellyfin. Lié à ${name}.",
|
||||
"borrowFromAnotherProfile": "Emprunter à un autre profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Réutilisez une connexion déjà associée à un autre profil. Les profils source protégés par PIN demandent le PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Accedi",
|
||||
"signInWithPlex": "Accedi con Plex",
|
||||
"showQRCode": "Mostra QR Code",
|
||||
"authenticate": "Autenticazione",
|
||||
"authenticationTimeout": "Autenticazione scaduta. Riprova.",
|
||||
"scanQRToSignIn": "Scansiona il QR code per accedere",
|
||||
"waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser.",
|
||||
"useBrowser": "Usa browser"
|
||||
"useBrowser": "Usa browser",
|
||||
"or": "o",
|
||||
"connectToJellyfin": "Connetti a Jellyfin",
|
||||
"useQuickConnect": "Usa Quick Connect",
|
||||
"quickConnectCode": "Codice Quick Connect",
|
||||
"quickConnectInstructions": "Apri il tuo server Jellyfin in un browser, accedi e scegli Quick Connect dal menu utente. Inserisci questo codice per approvare l'accesso.",
|
||||
"quickConnectWaiting": "In attesa di approvazione…",
|
||||
"quickConnectCancel": "Annulla",
|
||||
"quickConnectExpired": "Il codice Quick Connect è scaduto prima dell'approvazione. Riprova."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancella",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Griglia",
|
||||
"listView": "Elenco",
|
||||
"showHeroSection": "Mostra sezione principale",
|
||||
"useGlobalHubs": "Usa layout Home di Plex",
|
||||
"useGlobalHubsDescription": "Mostra gli hub della home page come il client Plex ufficiale. Se disattivato, mostra invece i suggerimenti per libreria.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Mostra nome server sugli hub",
|
||||
"showServerNameOnHubsDescription": "Mostra sempre il nome del server nei titoli degli hub. Se disattivato, solo per nomi hub duplicati.",
|
||||
"groupLibrariesByServer": "Raggruppa librerie per server",
|
||||
"groupLibrariesByServerDescription": "Mostra un'intestazione per ogni server Plex nella barra laterale quando sei connesso a più server.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Mantieni sempre aperta la barra laterale",
|
||||
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
|
||||
"showUnwatchedCount": "Mostra conteggio non visti",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
|
||||
"noDescriptionAvailable": "Nessuna descrizione disponibile",
|
||||
"noProfilesAvailable": "Nessun profilo disponibile",
|
||||
"contactAdminForProfiles": "Contatta il tuo amministratore Plex per aggiungere profili",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Impossibile determinare la sezione della libreria per questo elemento",
|
||||
"logsCleared": "Log eliminati",
|
||||
"logsCopied": "Log copiati negli appunti",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Conferma azione"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Aggiungi profilo Plezy",
|
||||
"switchingProfile": "Cambio profilo…",
|
||||
"deleteThisProfileTitle": "Eliminare questo profilo?",
|
||||
"deleteThisProfileMessage": "${displayName} verrà rimosso. Le connessioni non saranno influenzate.",
|
||||
"active": "Attivo",
|
||||
"manage": "Gestisci",
|
||||
"delete": "Elimina",
|
||||
"signOut": "Esci",
|
||||
"signOutPlexTitle": "Uscire da Plex?",
|
||||
"signOutPlexMessage": "${displayName} e tutti gli utenti Plex Home di questo account verranno rimossi da questo dispositivo. Puoi accedere di nuovo in qualsiasi momento.",
|
||||
"signedOutPlex": "Uscito da Plex.",
|
||||
"signOutFailed": "Uscita non riuscita.",
|
||||
"sectionTitle": "Profili",
|
||||
"summarySingle": "Aggiungi profili per combinare utenti gestiti e identità locali",
|
||||
"summaryMultipleWithActive": "${count} profili · attivo: ${activeName}",
|
||||
"summaryMultiple": "${count} profili",
|
||||
"removeConnectionTitle": "Rimuovere la connessione?",
|
||||
"removeConnectionMessage": "${displayName} perderà l'accesso a ${connectionLabel}. La connessione resta disponibile per gli altri profili.",
|
||||
"deleteProfileTitle": "Eliminare il profilo?",
|
||||
"deleteProfileMessage": "Questo rimuove ${displayName} e tutte le sue connessioni da questo dispositivo. I server Plex/Jellyfin sottostanti non sono interessati.",
|
||||
"profileNameLabel": "Nome profilo",
|
||||
"pinProtectionLabel": "Protezione PIN",
|
||||
"pinManagedByPlex": "PIN gestito da Plex. Modifica su plex.tv.",
|
||||
"noPinSetEditOnPlex": "Nessun PIN impostato. Per richiederne uno, modifica l'utente Home su plex.tv.",
|
||||
"setPin": "Imposta PIN",
|
||||
"connectionsLabel": "Connessioni",
|
||||
"add": "Aggiungi",
|
||||
"deleteProfileButton": "Elimina profilo",
|
||||
"noConnectionsHint": "Nessuna connessione — aggiungine una per usare questo profilo.",
|
||||
"plexHomeAccount": "Account Plex Home",
|
||||
"connectionDefault": "Predefinita",
|
||||
"makeDefault": "Imposta come predefinita",
|
||||
"removeConnection": "Rimuovi",
|
||||
"borrowAddTo": "Aggiungi a ${displayName}",
|
||||
"borrowExplain": "Prendi in prestito una connessione da un altro profilo. I profili sorgente protetti da PIN richiedono il PIN prima di condividere.",
|
||||
"borrowEmpty": "Nulla da prendere in prestito al momento.",
|
||||
"borrowEmptySubtitle": "Collega prima un account Plex o un server Jellyfin a un altro profilo, poi torna qui.",
|
||||
"newProfile": "Nuovo profilo",
|
||||
"profileNameHint": "es. Ospiti, Bambini, Soggiorno",
|
||||
"pinProtectionOptional": "Protezione PIN (opzionale)",
|
||||
"pinExplain": "PIN a 4 cifre richiesto per passare a questo profilo. Barriera leggera — chiunque può cancellare i dati dell'app per aggirarla.",
|
||||
"continueButton": "Continua",
|
||||
"pinsDontMatch": "I PIN non corrispondono"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Connessioni",
|
||||
"addConnection": "Aggiungi connessione",
|
||||
"addConnectionSubtitleNoProfile": "Accedi con Plex o collega un server Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Aggiungi a ${displayName} — account Plex, server Jellyfin o prendi in prestito da un altro profilo",
|
||||
"sessionExpiredOne": "Sessione scaduta per ${name}",
|
||||
"sessionExpiredMany": "Sessione scaduta per ${count} server",
|
||||
"signInAgain": "Accedi di nuovo"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Esplora",
|
||||
"switchProfile": "Cambia profilo",
|
||||
"noContentAvailable": "Nessun contenuto disponibile",
|
||||
"addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie",
|
||||
"continueWatching": "Continua a guardare",
|
||||
"nextUp": "Prossimi",
|
||||
"recentlyAdded": "Aggiunti di recente",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Panoramica",
|
||||
"cast": "Attori",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Ricerca fallita: ${error}",
|
||||
"connectionTimeout": "Timeout connessione durante caricamento di ${context}",
|
||||
"connectionFailed": "Impossibile connettersi al server Plex.",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Impossibile caricare ${context}: ${error}",
|
||||
"noClientAvailable": "Nessun client disponibile",
|
||||
"authenticationFailed": "Autenticazione fallita: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Inserisci token",
|
||||
"invalidToken": "Token non valido",
|
||||
"failedToVerifyToken": "Verifica token fallita: ${error}",
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}"
|
||||
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
|
||||
"failedToDeleteProfile": "Impossibile eliminare ${displayName}",
|
||||
"failedToRate": "Impossibile aggiornare la valutazione"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Librerie",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Stagioni",
|
||||
"episodes": "Episodi",
|
||||
"folders": "Cartelle"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genere",
|
||||
"year": "Anno",
|
||||
"contentRating": "Classificazione",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titolo",
|
||||
"dateAdded": "Data di aggiunta",
|
||||
"releaseDate": "Data di uscita",
|
||||
"rating": "Valutazione",
|
||||
"lastPlayed": "Ultima riproduzione",
|
||||
"playCount": "Riproduzioni",
|
||||
"random": "Casuale",
|
||||
"dateShared": "Data di condivisione",
|
||||
"latestEpisodeAirDate": "Data ultima messa in onda"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni",
|
||||
"openSourceLicenses": "Licenze Open Source",
|
||||
"versionLabel": "Versione ${version}",
|
||||
"appDescription": "Un bellissimo client Plex per Flutter",
|
||||
"appDescription": "Un bellissimo client Plex e Jellyfin per Flutter",
|
||||
"viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "In attesa che gli altri carichino...",
|
||||
"recentRooms": "Stanze recenti",
|
||||
"renameRoom": "Rinomina stanza",
|
||||
"removeRoom": "Rimuovi"
|
||||
"removeRoom": "Rimuovi",
|
||||
"guestSwitchUnavailable": "Impossibile cambiare — server non disponibile per la sincronizzazione",
|
||||
"guestSwitchFailed": "Impossibile cambiare — contenuto non trovato su questo server"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Download",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Filtro di sincronizzazione",
|
||||
"syncAllItems": "Sincronizzazione di tutti gli elementi",
|
||||
"syncUnwatchedItems": "Sincronizzazione degli elementi non visti",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Disponibile",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Accesso richiesto",
|
||||
"syncRuleNotAvailableForProfile": "Non disponibile per il profilo attuale",
|
||||
"syncRuleUnknownServer": "Server sconosciuto",
|
||||
"syncRuleListCreated": "Regola di sincronizzazione creata"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Librerie",
|
||||
"noLibraries": "Nessuna libreria disponibile"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Aggiungi server Jellyfin",
|
||||
"jellyfinUrlIntro": "Inserisci l'URL del tuo server Jellyfin — es. `https://jellyfin.example.com`. Potrai accedere subito dopo.",
|
||||
"serverUrl": "URL del server",
|
||||
"findServer": "Trova server",
|
||||
"username": "Nome utente",
|
||||
"password": "Password",
|
||||
"signIn": "Accedi",
|
||||
"change": "Modifica",
|
||||
"required": "Obbligatorio",
|
||||
"couldNotReachServer": "Impossibile raggiungere il server: ${error}",
|
||||
"signInFailed": "Accesso non riuscito: ${error}",
|
||||
"quickConnectFailed": "Quick Connect non riuscito: ${error}",
|
||||
"addPlexTitle": "Accedi con Plex",
|
||||
"plexAuthIntro": "Scegli come accedere a Plex. Il flusso browser apre plex.tv dove confermi la connessione; l'opzione QR è comoda per TV / dispositivi remoti.",
|
||||
"plexQRPrompt": "Scansiona questo QR code per accedere.",
|
||||
"waitingForPlexConfirmation": "In attesa della conferma da plex.tv…",
|
||||
"pinExpired": "PIN scaduto prima dell'accesso. Riprova.",
|
||||
"duplicatePlexAccount": "Questo dispositivo è già connesso a un account Plex. Disconnettiti dalle impostazioni per cambiare account.",
|
||||
"failedToRegisterAccount": "Registrazione account non riuscita: ${error}",
|
||||
"enterJellyfinUrlError": "Inserisci l'URL del tuo server Jellyfin",
|
||||
"addConnectionTitle": "Aggiungi connessione",
|
||||
"addConnectionTitleScoped": "Aggiungi a ${name}",
|
||||
"addConnectionIntroGlobal": "Aggiungi un altro server media. Puoi combinare account Plex e server Jellyfin — i contenuti di ogni backend connesso compaiono insieme nella schermata principale.",
|
||||
"addConnectionIntroScoped": "Aggiungi un nuovo server, o prendine in prestito uno da un altro profilo.",
|
||||
"signInWithPlexCard": "Accedi con Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorizza questo dispositivo con il tuo account Plex. I server condivisi con l'account vengono inclusi automaticamente.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorizza un nuovo account Plex. I suoi utenti Home appaiono come profili.",
|
||||
"connectToJellyfinCard": "Connetti a Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Inserisci l'URL del tuo server Jellyfin e accedi con nome utente + password (Quick Connect in arrivo).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Accedi a un server Jellyfin. Collegato a ${name}.",
|
||||
"borrowFromAnotherProfile": "Prendi in prestito da un altro profilo",
|
||||
"borrowFromAnotherProfileSubtitle": "Riutilizza una connessione già associata a un altro profilo. I profili sorgente protetti da PIN richiedono il PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "サインイン",
|
||||
"signInWithPlex": "Plexでサインイン",
|
||||
"showQRCode": "QRコードを表示",
|
||||
"authenticate": "認証",
|
||||
"authenticationTimeout": "認証がタイムアウトしました。もう一度お試しください。",
|
||||
"scanQRToSignIn": "このQRコードをスキャンしてサインイン",
|
||||
"waitingForAuth": "認証を待機中...\nブラウザでサインインを完了してください。",
|
||||
"useBrowser": "ブラウザを使用"
|
||||
"useBrowser": "ブラウザを使用",
|
||||
"or": "または",
|
||||
"connectToJellyfin": "Jellyfinに接続",
|
||||
"useQuickConnect": "Quick Connect を使う",
|
||||
"quickConnectCode": "Quick Connect コード",
|
||||
"quickConnectInstructions": "Web ブラウザで Jellyfin サーバーを開いてログインし、ユーザーメニューから Quick Connect を選択します。このコードを入力してサインインを承認してください。",
|
||||
"quickConnectWaiting": "承認を待っています…",
|
||||
"quickConnectCancel": "キャンセル",
|
||||
"quickConnectExpired": "承認される前に Quick Connect コードの有効期限が切れました。もう一度お試しください。"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "キャンセル",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "グリッド",
|
||||
"listView": "リスト",
|
||||
"showHeroSection": "ヒーローセクションを表示",
|
||||
"useGlobalHubs": "Plex Homeレイアウトを使用",
|
||||
"useGlobalHubsDescription": "公式Plexクライアントのようにホームページのハブを表示。オフにすると、ライブラリごとのおすすめを表示。",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "ハブにサーバー名を表示",
|
||||
"showServerNameOnHubsDescription": "ハブタイトルに常にサーバー名を表示。オフにすると、重複名のみ表示。",
|
||||
"groupLibrariesByServer": "サーバーごとにライブラリをグループ化",
|
||||
"groupLibrariesByServerDescription": "複数のサーバーに接続しているとき、サイドバーに各 Plex サーバーのヘッダーを表示します。",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "サイドバーを常に開いておく",
|
||||
"alwaysKeepSidebarOpenDescription": "サイドバーを展開したまま、コンテンツ領域が調整される",
|
||||
"showUnwatchedCount": "未視聴数を表示",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
||||
"noDescriptionAvailable": "説明はありません",
|
||||
"noProfilesAvailable": "利用可能なプロフィールがありません",
|
||||
"contactAdminForProfiles": "プロフィールを追加するにはPlex管理者にお問い合わせください",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "このアイテムのライブラリセクションを判別できません",
|
||||
"logsCleared": "ログをクリアしました",
|
||||
"logsCopied": "ログをクリップボードにコピーしました",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "操作の確認"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Plezyプロファイルを追加",
|
||||
"switchingProfile": "プロファイルを切り替え中…",
|
||||
"deleteThisProfileTitle": "このプロファイルを削除しますか?",
|
||||
"deleteThisProfileMessage": "${displayName} が削除されます。接続自体は影響を受けません。",
|
||||
"active": "アクティブ",
|
||||
"manage": "管理",
|
||||
"delete": "削除",
|
||||
"signOut": "サインアウト",
|
||||
"signOutPlexTitle": "Plex からサインアウトしますか?",
|
||||
"signOutPlexMessage": "${displayName} とこのアカウントのすべての Plex Home ユーザーがこのデバイスから削除されます。いつでも再度サインインできます。",
|
||||
"signedOutPlex": "Plex からサインアウトしました。",
|
||||
"signOutFailed": "サインアウトに失敗しました。",
|
||||
"sectionTitle": "プロファイル",
|
||||
"summarySingle": "プロファイルを追加して、管理対象ユーザーとローカルIDを混在させます",
|
||||
"summaryMultipleWithActive": "${count}個のプロファイル · アクティブ: ${activeName}",
|
||||
"summaryMultiple": "${count}個のプロファイル",
|
||||
"removeConnectionTitle": "接続を削除しますか?",
|
||||
"removeConnectionMessage": "${displayName}は${connectionLabel}へのアクセスを失います。接続自体は他のプロファイルで引き続き使用できます。",
|
||||
"deleteProfileTitle": "プロファイルを削除しますか?",
|
||||
"deleteProfileMessage": "このデバイスから${displayName}とそのすべての接続が削除されます。Plex/Jellyfinサーバー自体には影響しません。",
|
||||
"profileNameLabel": "プロファイル名",
|
||||
"pinProtectionLabel": "PIN保護",
|
||||
"pinManagedByPlex": "PINはPlexで管理されています。plex.tvで編集してください。",
|
||||
"noPinSetEditOnPlex": "PINが設定されていません。要求するには、plex.tvでHomeユーザーを編集してください。",
|
||||
"setPin": "PINを設定",
|
||||
"connectionsLabel": "接続",
|
||||
"add": "追加",
|
||||
"deleteProfileButton": "プロファイルを削除",
|
||||
"noConnectionsHint": "接続がありません — このプロファイルを使うには1つ追加してください。",
|
||||
"plexHomeAccount": "Plex Homeアカウント",
|
||||
"connectionDefault": "デフォルト",
|
||||
"makeDefault": "デフォルトに設定",
|
||||
"removeConnection": "削除",
|
||||
"borrowAddTo": "${displayName}に追加",
|
||||
"borrowExplain": "別のプロファイルから接続を借ります。PIN保護されたソースプロファイルは、共有前にPINを要求します。",
|
||||
"borrowEmpty": "まだ借りるものがありません。",
|
||||
"borrowEmptySubtitle": "まず別のプロファイルにPlexアカウントまたはJellyfinサーバーを接続してから、ここに戻ってきてください。",
|
||||
"newProfile": "新しいプロファイル",
|
||||
"profileNameHint": "例:ゲスト、キッズ、ファミリールーム",
|
||||
"pinProtectionOptional": "PIN保護(オプション)",
|
||||
"pinExplain": "このプロファイルに切り替えるには4桁のPINが必要です。ソフトバリア — アプリデータを消去できる人なら回避できます。",
|
||||
"continueButton": "続ける",
|
||||
"pinsDontMatch": "PINが一致しません"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "接続",
|
||||
"addConnection": "接続を追加",
|
||||
"addConnectionSubtitleNoProfile": "Plexでサインインするか、Jellyfinサーバーに接続",
|
||||
"addConnectionSubtitleScoped": "${displayName} に追加 — Plexアカウント、Jellyfinサーバー、または別のプロファイルから借用",
|
||||
"sessionExpiredOne": "${name} のセッションの有効期限が切れました",
|
||||
"sessionExpiredMany": "${count} 台のサーバーのセッションの有効期限が切れました",
|
||||
"signInAgain": "再度サインイン"
|
||||
},
|
||||
"discover": {
|
||||
"title": "探す",
|
||||
"switchProfile": "プロフィール切替",
|
||||
"noContentAvailable": "コンテンツがありません",
|
||||
"addMediaToLibraries": "ライブラリにメディアを追加してください",
|
||||
"continueWatching": "視聴を続ける",
|
||||
"nextUp": "次のエピソード",
|
||||
"recentlyAdded": "最近追加",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "あらすじ",
|
||||
"cast": "キャスト",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "検索に失敗しました: ${error}",
|
||||
"connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました",
|
||||
"connectionFailed": "Plexサーバーに接続できません",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "${context}の読み込みに失敗しました: ${error}",
|
||||
"noClientAvailable": "クライアントが利用できません",
|
||||
"authenticationFailed": "認証に失敗しました: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "トークンを入力してください",
|
||||
"invalidToken": "無効なトークン",
|
||||
"failedToVerifyToken": "トークンの検証に失敗しました: ${error}",
|
||||
"failedToSwitchProfile": "${displayName}への切替に失敗しました"
|
||||
"failedToSwitchProfile": "${displayName}への切替に失敗しました",
|
||||
"failedToDeleteProfile": "${displayName}の削除に失敗しました",
|
||||
"failedToRate": "評価を更新できませんでした"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "ライブラリ",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "シーズン",
|
||||
"episodes": "エピソード",
|
||||
"folders": "フォルダ"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "ジャンル",
|
||||
"year": "年",
|
||||
"contentRating": "視聴年齢区分",
|
||||
"tag": "タグ"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "タイトル",
|
||||
"dateAdded": "追加日",
|
||||
"releaseDate": "リリース日",
|
||||
"rating": "評価",
|
||||
"lastPlayed": "最終再生",
|
||||
"playCount": "再生回数",
|
||||
"random": "ランダム",
|
||||
"dateShared": "共有日",
|
||||
"latestEpisodeAirDate": "最新エピソード放送日"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "アプリについて",
|
||||
"openSourceLicenses": "オープンソースライセンス",
|
||||
"versionLabel": "バージョン ${version}",
|
||||
"appDescription": "Flutter製の美しいPlexクライアント",
|
||||
"appDescription": "Flutter製の美しいPlex・Jellyfinクライアント",
|
||||
"viewLicensesDescription": "サードパーティライブラリのライセンスを表示"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "他の参加者の読み込みを待っています...",
|
||||
"recentRooms": "最近のルーム",
|
||||
"renameRoom": "ルーム名を変更",
|
||||
"removeRoom": "削除"
|
||||
"removeRoom": "削除",
|
||||
"guestSwitchUnavailable": "切り替えできません — サーバーが同期できません",
|
||||
"guestSwitchFailed": "切り替えできません — このサーバーにコンテンツが見つかりません"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "ダウンロード",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "同期フィルター",
|
||||
"syncAllItems": "すべてのアイテムを同期中",
|
||||
"syncUnwatchedItems": "未視聴のアイテムを同期中",
|
||||
"syncRuleServerContext": "サーバー: ${server} • ${status}",
|
||||
"syncRuleAvailable": "利用可能",
|
||||
"syncRuleOffline": "オフライン",
|
||||
"syncRuleSignInRequired": "サインインが必要",
|
||||
"syncRuleNotAvailableForProfile": "現在のプロフィールでは利用できません",
|
||||
"syncRuleUnknownServer": "不明なサーバー",
|
||||
"syncRuleListCreated": "同期ルールを作成しました"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "ライブラリ",
|
||||
"noLibraries": "利用できるライブラリがありません"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfinサーバーを追加",
|
||||
"jellyfinUrlIntro": "JellyfinサーバーのURLを入力してください — 例: `https://jellyfin.example.com`。サインインは後から行えます。",
|
||||
"serverUrl": "サーバーURL",
|
||||
"findServer": "サーバーを検索",
|
||||
"username": "ユーザー名",
|
||||
"password": "パスワード",
|
||||
"signIn": "サインイン",
|
||||
"change": "変更",
|
||||
"required": "必須",
|
||||
"couldNotReachServer": "サーバーに接続できませんでした: ${error}",
|
||||
"signInFailed": "サインインに失敗しました: ${error}",
|
||||
"quickConnectFailed": "Quick Connectに失敗しました: ${error}",
|
||||
"addPlexTitle": "Plexでサインイン",
|
||||
"plexAuthIntro": "Plexへのサインイン方法を選択します。ブラウザフローではplex.tvが開き、接続を確認します。QRオプションはTVやリモートデバイスに便利です。",
|
||||
"plexQRPrompt": "このQRコードをスキャンしてサインインしてください。",
|
||||
"waitingForPlexConfirmation": "plex.tvがサインインを確認するのを待っています…",
|
||||
"pinExpired": "サインイン前にPINの有効期限が切れました。もう一度お試しください。",
|
||||
"duplicatePlexAccount": "このデバイスはすでにPlexアカウントにサインインしています。アカウントを切り替えるには設定からサインアウトしてください。",
|
||||
"failedToRegisterAccount": "アカウントの登録に失敗しました: ${error}",
|
||||
"enterJellyfinUrlError": "JellyfinサーバーのURLを入力してください",
|
||||
"addConnectionTitle": "接続を追加",
|
||||
"addConnectionTitleScoped": "${name}に追加",
|
||||
"addConnectionIntroGlobal": "別のメディアサーバーを追加します。PlexアカウントとJellyfinサーバーを組み合わせて使用でき、接続済みのすべてのバックエンドのアイテムがホーム画面に並びます。",
|
||||
"addConnectionIntroScoped": "新しいサーバーを追加するか、別のプロファイルから借りてください。",
|
||||
"signInWithPlexCard": "Plexでサインイン",
|
||||
"signInWithPlexCardSubtitle": "このデバイスをあなたのPlexアカウントで認証します。アカウントで共有されているサーバーは自動的に含まれます。",
|
||||
"signInWithPlexCardSubtitleScoped": "新しいPlexアカウントを認証します。そのHomeユーザーがプロファイルとして表示されます。",
|
||||
"connectToJellyfinCard": "Jellyfinに接続",
|
||||
"connectToJellyfinCardSubtitle": "JellyfinサーバーのURLを入力し、ユーザー名+パスワードでサインインします(Quick Connectは近日対応予定)。",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfinサーバーにサインインします。${name}に紐付けられます。",
|
||||
"borrowFromAnotherProfile": "別のプロファイルから借りる",
|
||||
"borrowFromAnotherProfileSubtitle": "別のプロファイルに紐付け済みの接続を再利用します。PIN保護されたソースプロファイルはPINの入力を求めます。"
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "로그인",
|
||||
"signInWithPlex": "Plex 계정으로 로그인",
|
||||
"showQRCode": "QR 코드",
|
||||
"authenticate": "인증",
|
||||
"authenticationTimeout": "인증 시간이 초과되었습니다. 다시 시도해 주세요.",
|
||||
"scanQRToSignIn": "QR 코드를 스캔하여 로그인",
|
||||
"waitingForAuth": "인증 대기 중... 브라우저에서 로그인을 완료해 주세요.",
|
||||
"useBrowser": "브라우저 사용"
|
||||
"useBrowser": "브라우저 사용",
|
||||
"or": "또는",
|
||||
"connectToJellyfin": "Jellyfin에 연결",
|
||||
"useQuickConnect": "Quick Connect 사용",
|
||||
"quickConnectCode": "Quick Connect 코드",
|
||||
"quickConnectInstructions": "웹 브라우저에서 Jellyfin 서버를 열어 로그인한 뒤 사용자 메뉴에서 Quick Connect를 선택하세요. 이 코드를 입력해 로그인을 승인하세요.",
|
||||
"quickConnectWaiting": "승인 대기 중…",
|
||||
"quickConnectCancel": "취소",
|
||||
"quickConnectExpired": "승인되기 전에 Quick Connect 코드가 만료되었습니다. 다시 시도하세요."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "취소",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "그리드 보기",
|
||||
"listView": "목록 보기",
|
||||
"showHeroSection": "주요 추천 영역 표시",
|
||||
"useGlobalHubs": "Plex 홈 레이아웃 사용",
|
||||
"useGlobalHubsDescription": "공식 Plex 클라이언트처럼 홈 페이지 허브를 표시합니다. 끄면 라이브러리별 추천이 대신 표시됩니다.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "허브에 서버 이름 표시",
|
||||
"showServerNameOnHubsDescription": "허브 제목에 항상 서버 이름을 표시합니다. 끄면 중복된 허브 이름에만 표시됩니다.",
|
||||
"groupLibrariesByServer": "서버별로 라이브러리 그룹화",
|
||||
"groupLibrariesByServerDescription": "여러 서버에 연결되어 있을 때 사이드바에 각 Plex 서버의 헤더를 표시합니다.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "사이드바 항상 열어두기",
|
||||
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
|
||||
"showUnwatchedCount": "미시청 수 표시",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "음악 재생 미지원",
|
||||
"noDescriptionAvailable": "설명이 없습니다",
|
||||
"noProfilesAvailable": "사용 가능한 프로필이 없습니다",
|
||||
"contactAdminForProfiles": "프로필을 추가하려면 Plex 관리자에게 문의하세요",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "이 항목의 라이브러리 섹션을 확인할 수 없습니다",
|
||||
"logsCleared": "로그가 삭제 되었습니다",
|
||||
"logsCopied": "로그가 클립보드에 복사 되었습니다",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "확인"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Plezy 프로필 추가",
|
||||
"switchingProfile": "프로필 전환 중…",
|
||||
"deleteThisProfileTitle": "이 프로필을 삭제하시겠습니까?",
|
||||
"deleteThisProfileMessage": "${displayName} 이(가) 제거됩니다. 연결 자체는 영향을 받지 않습니다.",
|
||||
"active": "활성",
|
||||
"manage": "관리",
|
||||
"delete": "삭제",
|
||||
"signOut": "로그아웃",
|
||||
"signOutPlexTitle": "Plex에서 로그아웃하시겠습니까?",
|
||||
"signOutPlexMessage": "${displayName} 및 이 계정의 모든 Plex Home 사용자가 이 기기에서 제거됩니다. 언제든지 다시 로그인할 수 있습니다.",
|
||||
"signedOutPlex": "Plex에서 로그아웃되었습니다.",
|
||||
"signOutFailed": "로그아웃에 실패했습니다.",
|
||||
"sectionTitle": "프로필",
|
||||
"summarySingle": "관리되는 사용자와 로컬 ID를 혼합하려면 프로필을 추가하세요",
|
||||
"summaryMultipleWithActive": "${count}개 프로필 · 활성: ${activeName}",
|
||||
"summaryMultiple": "${count}개 프로필",
|
||||
"removeConnectionTitle": "연결을 제거하시겠습니까?",
|
||||
"removeConnectionMessage": "${displayName}이(가) ${connectionLabel}에 대한 액세스를 잃게 됩니다. 연결 자체는 다른 프로필에서 계속 사용할 수 있습니다.",
|
||||
"deleteProfileTitle": "프로필을 삭제하시겠습니까?",
|
||||
"deleteProfileMessage": "이 기기에서 ${displayName}과(와) 모든 연결이 제거됩니다. 기본 Plex/Jellyfin 서버에는 영향을 주지 않습니다.",
|
||||
"profileNameLabel": "프로필 이름",
|
||||
"pinProtectionLabel": "PIN 보호",
|
||||
"pinManagedByPlex": "PIN은 Plex에서 관리됩니다. plex.tv에서 편집하세요.",
|
||||
"noPinSetEditOnPlex": "설정된 PIN이 없습니다. 요구하려면 plex.tv에서 Home 사용자를 편집하세요.",
|
||||
"setPin": "PIN 설정",
|
||||
"connectionsLabel": "연결",
|
||||
"add": "추가",
|
||||
"deleteProfileButton": "프로필 삭제",
|
||||
"noConnectionsHint": "연결이 없습니다 — 이 프로필을 사용하려면 하나 추가하세요.",
|
||||
"plexHomeAccount": "Plex Home 계정",
|
||||
"connectionDefault": "기본값",
|
||||
"makeDefault": "기본값으로 설정",
|
||||
"removeConnection": "제거",
|
||||
"borrowAddTo": "${displayName}에 추가",
|
||||
"borrowExplain": "다른 프로필에서 연결을 빌립니다. PIN 보호된 원본 프로필은 공유 전에 PIN을 요구합니다.",
|
||||
"borrowEmpty": "아직 빌릴 것이 없습니다.",
|
||||
"borrowEmptySubtitle": "먼저 다른 프로필에 Plex 계정 또는 Jellyfin 서버를 연결한 다음 여기로 돌아오세요.",
|
||||
"newProfile": "새 프로필",
|
||||
"profileNameHint": "예: 손님, 어린이, 가족실",
|
||||
"pinProtectionOptional": "PIN 보호 (선택 사항)",
|
||||
"pinExplain": "이 프로필로 전환하려면 4자리 PIN이 필요합니다. 부드러운 장벽 — 앱 데이터를 지울 수 있는 사람은 우회할 수 있습니다.",
|
||||
"continueButton": "계속",
|
||||
"pinsDontMatch": "PIN이 일치하지 않습니다"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "연결",
|
||||
"addConnection": "연결 추가",
|
||||
"addConnectionSubtitleNoProfile": "Plex로 로그인하거나 Jellyfin 서버에 연결",
|
||||
"addConnectionSubtitleScoped": "${displayName} 에 추가 — Plex 계정, Jellyfin 서버 또는 다른 프로필에서 빌리기",
|
||||
"sessionExpiredOne": "${name} 의 세션이 만료되었습니다",
|
||||
"sessionExpiredMany": "${count} 개의 서버에서 세션이 만료되었습니다",
|
||||
"signInAgain": "다시 로그인"
|
||||
},
|
||||
"discover": {
|
||||
"title": "발견",
|
||||
"switchProfile": "사용자 전환",
|
||||
"noContentAvailable": "사용 가능한 콘텐츠가 없습니다",
|
||||
"addMediaToLibraries": "미디어 라이브러리에 미디어를 추가해 주세요",
|
||||
"continueWatching": "계속 시청",
|
||||
"nextUp": "다음 에피소드",
|
||||
"recentlyAdded": "최근에 추가됨",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "개요",
|
||||
"cast": "출연진",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "검색 실패: ${error}",
|
||||
"connectionTimeout": "${context} 로드 중 연결 시간 초과",
|
||||
"connectionFailed": "Plex 서버에 연결할 수 없음",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "${context} 로드 실패: ${error}",
|
||||
"noClientAvailable": "사용 가능한 클라이언트가 없습니다",
|
||||
"authenticationFailed": "인증 실패: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "토큰을 입력해 주세요",
|
||||
"invalidToken": "토큰이 유효하지 않습니다",
|
||||
"failedToVerifyToken": "토큰을 확인할 수 없습니다: ${error}",
|
||||
"failedToSwitchProfile": "${displayName}으로 전환할 수 없습니다"
|
||||
"failedToSwitchProfile": "${displayName}으로 전환할 수 없습니다",
|
||||
"failedToDeleteProfile": "${displayName}을(를) 삭제할 수 없습니다",
|
||||
"failedToRate": "평점을 업데이트하지 못했습니다"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "미디어 라이브러리",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "시즌",
|
||||
"episodes": "화",
|
||||
"folders": "폴더"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "장르",
|
||||
"year": "연도",
|
||||
"contentRating": "시청 등급",
|
||||
"tag": "태그"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "제목",
|
||||
"dateAdded": "추가된 날짜",
|
||||
"releaseDate": "출시일",
|
||||
"rating": "평점",
|
||||
"lastPlayed": "마지막 재생",
|
||||
"playCount": "재생 횟수",
|
||||
"random": "무작위",
|
||||
"dateShared": "공유된 날짜",
|
||||
"latestEpisodeAirDate": "최신 에피소드 방영일"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "소개",
|
||||
"openSourceLicenses": "오픈소스 라이선스",
|
||||
"versionLabel": "버전 ${version}",
|
||||
"appDescription": "아름다운 Flutter Plex 클라이언트",
|
||||
"appDescription": "아름다운 Flutter용 Plex 및 Jellyfin 클라이언트",
|
||||
"viewLicensesDescription": "타사 라이브러리 라이선스 보기"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "다른 참가자의 로딩을 기다리는 중...",
|
||||
"recentRooms": "최근 방",
|
||||
"renameRoom": "방 이름 변경",
|
||||
"removeRoom": "제거"
|
||||
"removeRoom": "제거",
|
||||
"guestSwitchUnavailable": "전환할 수 없음 — 동기화 서버를 사용할 수 없습니다",
|
||||
"guestSwitchFailed": "전환할 수 없음 — 이 서버에서 콘텐츠를 찾을 수 없습니다"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "다운로드",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "동기화 필터",
|
||||
"syncAllItems": "모든 항목 동기화 중",
|
||||
"syncUnwatchedItems": "시청하지 않은 항목 동기화 중",
|
||||
"syncRuleServerContext": "서버: ${server} • ${status}",
|
||||
"syncRuleAvailable": "사용 가능",
|
||||
"syncRuleOffline": "오프라인",
|
||||
"syncRuleSignInRequired": "로그인 필요",
|
||||
"syncRuleNotAvailableForProfile": "현재 프로필에서 사용할 수 없음",
|
||||
"syncRuleUnknownServer": "알 수 없는 서버",
|
||||
"syncRuleListCreated": "동기화 규칙이 생성되었습니다"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "라이브러리",
|
||||
"noLibraries": "사용 가능한 라이브러리가 없습니다"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin 서버 추가",
|
||||
"jellyfinUrlIntro": "Jellyfin 서버 URL을 입력하세요 — 예: `https://jellyfin.example.com`. 이후에 로그인할 수 있습니다.",
|
||||
"serverUrl": "서버 URL",
|
||||
"findServer": "서버 찾기",
|
||||
"username": "사용자 이름",
|
||||
"password": "비밀번호",
|
||||
"signIn": "로그인",
|
||||
"change": "변경",
|
||||
"required": "필수",
|
||||
"couldNotReachServer": "서버에 연결할 수 없습니다: ${error}",
|
||||
"signInFailed": "로그인 실패: ${error}",
|
||||
"quickConnectFailed": "Quick Connect 실패: ${error}",
|
||||
"addPlexTitle": "Plex로 로그인",
|
||||
"plexAuthIntro": "Plex에 로그인할 방법을 선택하세요. 브라우저 플로우는 plex.tv를 열어 연결을 확인하며, QR 옵션은 TV나 원격 장치에 편리합니다.",
|
||||
"plexQRPrompt": "이 QR 코드를 스캔하여 로그인하세요.",
|
||||
"waitingForPlexConfirmation": "plex.tv에서 로그인을 확인하는 중…",
|
||||
"pinExpired": "로그인 전에 PIN이 만료되었습니다. 다시 시도하세요.",
|
||||
"duplicatePlexAccount": "이 기기는 이미 Plex 계정에 로그인되어 있습니다. 계정을 변경하려면 설정에서 로그아웃하세요.",
|
||||
"failedToRegisterAccount": "계정 등록 실패: ${error}",
|
||||
"enterJellyfinUrlError": "Jellyfin 서버 URL을 입력하세요",
|
||||
"addConnectionTitle": "연결 추가",
|
||||
"addConnectionTitleScoped": "${name}에 추가",
|
||||
"addConnectionIntroGlobal": "다른 미디어 서버를 추가하세요. Plex 계정과 Jellyfin 서버를 함께 사용할 수 있으며, 연결된 모든 백엔드의 항목이 홈 화면에 함께 표시됩니다.",
|
||||
"addConnectionIntroScoped": "새 서버를 추가하거나 다른 프로필에서 빌리세요.",
|
||||
"signInWithPlexCard": "Plex로 로그인",
|
||||
"signInWithPlexCardSubtitle": "이 기기를 Plex 계정으로 인증합니다. 계정과 공유된 서버가 자동으로 함께 추가됩니다.",
|
||||
"signInWithPlexCardSubtitleScoped": "새 Plex 계정을 인증합니다. 해당 Home 사용자가 프로필로 표시됩니다.",
|
||||
"connectToJellyfinCard": "Jellyfin에 연결",
|
||||
"connectToJellyfinCardSubtitle": "Jellyfin 서버 URL을 입력하고 사용자 이름 + 비밀번호로 로그인하세요 (Quick Connect는 곧 지원 예정).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Jellyfin 서버에 로그인합니다. ${name}에 연결됩니다.",
|
||||
"borrowFromAnotherProfile": "다른 프로필에서 빌리기",
|
||||
"borrowFromAnotherProfileSubtitle": "이미 다른 프로필에 연결된 연결을 재사용합니다. PIN으로 보호된 소스 프로필은 PIN을 요청합니다."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Logg på",
|
||||
"signInWithPlex": "Logg inn med Plex",
|
||||
"showQRCode": "Vis QR-kode",
|
||||
"authenticate": "Autentiser",
|
||||
"authenticationTimeout": "Autentiseringen ble tidsavbrutt. Prøv igjen.",
|
||||
"scanQRToSignIn": "Skann denne QR-koden for å logge inn",
|
||||
"waitingForAuth": "Venter på autentisering...\nFullfør innloggingen i nettleseren din.",
|
||||
"useBrowser": "Bruk nettleser"
|
||||
"useBrowser": "Bruk nettleser",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Koble til Jellyfin",
|
||||
"useQuickConnect": "Bruk Quick Connect",
|
||||
"quickConnectCode": "Quick Connect-kode",
|
||||
"quickConnectInstructions": "Åpne Jellyfin-serveren din i en nettleser, logg inn, og velg Quick Connect i brukermenyen. Skriv inn denne koden for å godkjenne påloggingen.",
|
||||
"quickConnectWaiting": "Venter på godkjenning…",
|
||||
"quickConnectCancel": "Avbryt",
|
||||
"quickConnectExpired": "Quick Connect-koden utløp før godkjenning. Prøv igjen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Avbryt",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Rutenett",
|
||||
"listView": "Liste",
|
||||
"showHeroSection": "Vis fremhevet seksjon",
|
||||
"useGlobalHubs": "Bruk Plex Home-layout",
|
||||
"useGlobalHubsDescription": "Vis hjemmeside-huber som den offisielle Plex-klienten. Når av, vises per-bibliotek-anbefalinger i stedet.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Vis servernavn på huber",
|
||||
"showServerNameOnHubsDescription": "Vis alltid servernavnet i hubtitler. Når av, vises kun for dupliserte hubnavn.",
|
||||
"groupLibrariesByServer": "Grupper biblioteker etter server",
|
||||
"groupLibrariesByServerDescription": "Vis en overskrift for hver Plex-server i sidefeltet når du er koblet til flere servere.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Hold sidefeltet alltid åpent",
|
||||
"alwaysKeepSidebarOpenDescription": "Sidefeltet forblir utvidet og innholdsområdet tilpasser seg",
|
||||
"showUnwatchedCount": "Vis antall usette",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Musikkavspilling støttes ikke ennå",
|
||||
"noDescriptionAvailable": "Ingen beskrivelse tilgjengelig",
|
||||
"noProfilesAvailable": "Ingen profiler tilgjengelige",
|
||||
"contactAdminForProfiles": "Kontakt Plex-administratoren for å legge til profiler",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Kan ikke fastslå bibliotekseksjonen for dette elementet",
|
||||
"logsCleared": "Logger tømt",
|
||||
"logsCopied": "Logger kopiert til utklippstavle",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Bekreft handling"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Legg til Plezy-profil",
|
||||
"switchingProfile": "Bytter profil…",
|
||||
"deleteThisProfileTitle": "Slett denne profilen?",
|
||||
"deleteThisProfileMessage": "${displayName} fjernes. Tilkoblinger påvirkes ikke.",
|
||||
"active": "Aktiv",
|
||||
"manage": "Administrer",
|
||||
"delete": "Slett",
|
||||
"signOut": "Logg ut",
|
||||
"signOutPlexTitle": "Logge ut av Plex?",
|
||||
"signOutPlexMessage": "${displayName} og alle Plex Home-brukere på denne kontoen fjernes fra denne enheten. Du kan logge inn igjen når som helst.",
|
||||
"signedOutPlex": "Logget ut av Plex.",
|
||||
"signOutFailed": "Utlogging mislyktes.",
|
||||
"sectionTitle": "Profiler",
|
||||
"summarySingle": "Legg til profiler for å blande administrerte brukere og lokale identiteter",
|
||||
"summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}",
|
||||
"summaryMultiple": "${count} profiler",
|
||||
"removeConnectionTitle": "Fjerne tilkobling?",
|
||||
"removeConnectionMessage": "${displayName} mister tilgang til ${connectionLabel}. Tilkoblingen er fortsatt tilgjengelig for andre profiler.",
|
||||
"deleteProfileTitle": "Slette profil?",
|
||||
"deleteProfileMessage": "Dette fjerner ${displayName} og alle dens tilkoblinger fra denne enheten. De underliggende Plex/Jellyfin-serverne berøres ikke.",
|
||||
"profileNameLabel": "Profilnavn",
|
||||
"pinProtectionLabel": "PIN-beskyttelse",
|
||||
"pinManagedByPlex": "PIN administreres av Plex. Rediger på plex.tv.",
|
||||
"noPinSetEditOnPlex": "Ingen PIN er satt. For å kreve én, rediger Home-brukeren på plex.tv.",
|
||||
"setPin": "Sett PIN",
|
||||
"connectionsLabel": "Tilkoblinger",
|
||||
"add": "Legg til",
|
||||
"deleteProfileButton": "Slett profil",
|
||||
"noConnectionsHint": "Ingen tilkoblinger — legg til én for å bruke denne profilen.",
|
||||
"plexHomeAccount": "Plex Home-konto",
|
||||
"connectionDefault": "Standard",
|
||||
"makeDefault": "Gjør til standard",
|
||||
"removeConnection": "Fjern",
|
||||
"borrowAddTo": "Legg til ${displayName}",
|
||||
"borrowExplain": "Lån en tilkobling fra en annen profil. PIN-beskyttede kildeprofiler ber om PIN før deling.",
|
||||
"borrowEmpty": "Ingenting å låne enda.",
|
||||
"borrowEmptySubtitle": "Koble først en Plex-konto eller Jellyfin-server til en annen profil, og kom så tilbake hit.",
|
||||
"newProfile": "Ny profil",
|
||||
"profileNameHint": "f.eks. Gjester, Barn, Familierom",
|
||||
"pinProtectionOptional": "PIN-beskyttelse (valgfri)",
|
||||
"pinExplain": "4-sifret PIN kreves for å bytte til denne profilen. Myk barriere — alle som kan fjerne appdata kan omgå den.",
|
||||
"continueButton": "Fortsett",
|
||||
"pinsDontMatch": "PIN-ene samsvarer ikke"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Tilkoblinger",
|
||||
"addConnection": "Legg til tilkobling",
|
||||
"addConnectionSubtitleNoProfile": "Logg inn med Plex eller koble til en Jellyfin-server",
|
||||
"addConnectionSubtitleScoped": "Legg til ${displayName} — Plex-konto, Jellyfin-server eller lån fra en annen profil",
|
||||
"sessionExpiredOne": "Økten er utløpt for ${name}",
|
||||
"sessionExpiredMany": "Økten er utløpt for ${count} servere",
|
||||
"signInAgain": "Logg inn igjen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Oppdag",
|
||||
"switchProfile": "Bytt profil",
|
||||
"noContentAvailable": "Ingen innhold tilgjengelig",
|
||||
"addMediaToLibraries": "Legg til medier i bibliotekene dine",
|
||||
"continueWatching": "Fortsett å se",
|
||||
"nextUp": "Neste opp",
|
||||
"recentlyAdded": "Nylig lagt til",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Oversikt",
|
||||
"cast": "Skuespillere",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Søk mislyktes: ${error}",
|
||||
"connectionTimeout": "Tidsavbrudd ved lasting av ${context}",
|
||||
"connectionFailed": "Kunne ikke koble til Plex-server",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Kunne ikke laste ${context}: ${error}",
|
||||
"noClientAvailable": "Ingen klient tilgjengelig",
|
||||
"authenticationFailed": "Autentisering mislyktes: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Vennligst skriv inn et token",
|
||||
"invalidToken": "Ugyldig token",
|
||||
"failedToVerifyToken": "Kunne ikke verifisere token: ${error}",
|
||||
"failedToSwitchProfile": "Kunne ikke bytte til ${displayName}"
|
||||
"failedToSwitchProfile": "Kunne ikke bytte til ${displayName}",
|
||||
"failedToDeleteProfile": "Kunne ikke slette ${displayName}",
|
||||
"failedToRate": "Kunne ikke oppdatere vurderingen"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Biblioteker",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Sesonger",
|
||||
"episodes": "Episoder",
|
||||
"folders": "Mapper"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Sjanger",
|
||||
"year": "År",
|
||||
"contentRating": "Aldersgrense",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Tittel",
|
||||
"dateAdded": "Lagt til-dato",
|
||||
"releaseDate": "Utgivelsesdato",
|
||||
"rating": "Vurdering",
|
||||
"lastPlayed": "Sist spilt",
|
||||
"playCount": "Avspillinger",
|
||||
"random": "Tilfeldig",
|
||||
"dateShared": "Delingsdato",
|
||||
"latestEpisodeAirDate": "Siste episodes sendedato"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Om",
|
||||
"openSourceLicenses": "Åpen kildekode-lisenser",
|
||||
"versionLabel": "Versjon ${version}",
|
||||
"appDescription": "En vakker Plex-klient for Flutter",
|
||||
"appDescription": "En vakker Plex- og Jellyfin-klient for Flutter",
|
||||
"viewLicensesDescription": "Vis lisenser for tredjepartsbiblioteker"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Venter på at andre laster inn...",
|
||||
"recentRooms": "Nylige rom",
|
||||
"renameRoom": "Gi nytt navn til rom",
|
||||
"removeRoom": "Fjern"
|
||||
"removeRoom": "Fjern",
|
||||
"guestSwitchUnavailable": "Kunne ikke bytte — server ikke tilgjengelig for synkronisering",
|
||||
"guestSwitchFailed": "Kunne ikke bytte — innhold ble ikke funnet på denne serveren"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Nedlastinger",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Synkroniseringsfilter",
|
||||
"syncAllItems": "Synkroniserer alle elementer",
|
||||
"syncUnwatchedItems": "Synkroniserer usette elementer",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Tilgjengelig",
|
||||
"syncRuleOffline": "Frakoblet",
|
||||
"syncRuleSignInRequired": "Innlogging kreves",
|
||||
"syncRuleNotAvailableForProfile": "Ikke tilgjengelig for gjeldende profil",
|
||||
"syncRuleUnknownServer": "Ukjent server",
|
||||
"syncRuleListCreated": "Synkroniseringsregel opprettet"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Biblioteker",
|
||||
"noLibraries": "Ingen biblioteker tilgjengelige"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Legg til Jellyfin-server",
|
||||
"jellyfinUrlIntro": "Oppgi URL-en til Jellyfin-serveren din — f.eks. `https://jellyfin.example.com`. Du kan logge på etterpå.",
|
||||
"serverUrl": "Server-URL",
|
||||
"findServer": "Finn server",
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord",
|
||||
"signIn": "Logg på",
|
||||
"change": "Endre",
|
||||
"required": "Påkrevd",
|
||||
"couldNotReachServer": "Kunne ikke nå serveren: ${error}",
|
||||
"signInFailed": "Pålogging mislyktes: ${error}",
|
||||
"quickConnectFailed": "Quick Connect mislyktes: ${error}",
|
||||
"addPlexTitle": "Logg på med Plex",
|
||||
"plexAuthIntro": "Velg hvordan du vil logge på Plex. Nettleserflyten åpner plex.tv der du bekrefter tilkoblingen; QR-alternativet er praktisk for TV / fjern-enheter.",
|
||||
"plexQRPrompt": "Skann denne QR-koden for å logge på.",
|
||||
"waitingForPlexConfirmation": "Venter på at plex.tv bekrefter påloggingen…",
|
||||
"pinExpired": "PIN-koden gikk ut før pålogging. Prøv igjen.",
|
||||
"duplicatePlexAccount": "Denne enheten er allerede pålogget en Plex-konto. Logg ut fra innstillingene for å bytte konto.",
|
||||
"failedToRegisterAccount": "Kunne ikke registrere kontoen: ${error}",
|
||||
"enterJellyfinUrlError": "Oppgi URL-en til Jellyfin-serveren din",
|
||||
"addConnectionTitle": "Legg til tilkobling",
|
||||
"addConnectionTitleScoped": "Legg til i ${name}",
|
||||
"addConnectionIntroGlobal": "Legg til enda en medieserver. Du kan blande Plex-kontoer og Jellyfin-servere — innhold fra alle tilkoblede backender vises sammen på startsiden.",
|
||||
"addConnectionIntroScoped": "Legg til en ny server, eller lån en fra en annen profil.",
|
||||
"signInWithPlexCard": "Logg på med Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriser denne enheten mot Plex-kontoen din. Servere delt med kontoen følger med automatisk.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriser en ny Plex-konto. Dens Home-brukere vises som profiler.",
|
||||
"connectToJellyfinCard": "Koble til Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Oppgi URL-en til Jellyfin-serveren din og logg på med brukernavn + passord (Quick Connect kommer snart).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Logg på en Jellyfin-server. Knyttes til ${name}.",
|
||||
"borrowFromAnotherProfile": "Lån fra en annen profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Gjenbruk en tilkobling som allerede er tilknyttet en annen profil. PIN-beskyttede kildeprofiler ber om PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Inloggen",
|
||||
"signInWithPlex": "Inloggen met Plex",
|
||||
"showQRCode": "Toon QR-code",
|
||||
"authenticate": "Authenticeren",
|
||||
"authenticationTimeout": "Authenticatie verlopen. Probeer opnieuw.",
|
||||
"scanQRToSignIn": "Scan deze QR-code om in te loggen",
|
||||
"waitingForAuth": "Wachten op authenticatie...\nVoltooi het inloggen in je browser.",
|
||||
"useBrowser": "Gebruik browser"
|
||||
"useBrowser": "Gebruik browser",
|
||||
"or": "of",
|
||||
"connectToJellyfin": "Verbinden met Jellyfin",
|
||||
"useQuickConnect": "Quick Connect gebruiken",
|
||||
"quickConnectCode": "Quick Connect-code",
|
||||
"quickConnectInstructions": "Open je Jellyfin-server in een webbrowser, log in en kies Quick Connect in het gebruikersmenu. Voer deze code in om de aanmelding goed te keuren.",
|
||||
"quickConnectWaiting": "Wachten op goedkeuring…",
|
||||
"quickConnectCancel": "Annuleren",
|
||||
"quickConnectExpired": "De Quick Connect-code is verlopen voordat hij werd goedgekeurd. Probeer het opnieuw."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Annuleren",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Raster",
|
||||
"listView": "Lijst",
|
||||
"showHeroSection": "Toon hoofdsectie",
|
||||
"useGlobalHubs": "Plex Home-indeling gebruiken",
|
||||
"useGlobalHubsDescription": "Toon startpagina-hubs zoals de officiële Plex-client. Indien uitgeschakeld, worden in plaats daarvan aanbevelingen per bibliotheek getoond.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Servernaam tonen bij hubs",
|
||||
"showServerNameOnHubsDescription": "Toon altijd de servernaam in hub-titels. Indien uitgeschakeld, alleen bij dubbele hub-namen.",
|
||||
"groupLibrariesByServer": "Bibliotheken groeperen per server",
|
||||
"groupLibrariesByServerDescription": "Toont een kop voor elke Plex-server in de zijbalk wanneer je met meerdere servers verbonden bent.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Zijbalk altijd open houden",
|
||||
"alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan",
|
||||
"showUnwatchedCount": "Aantal ongekeken tonen",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
|
||||
"noDescriptionAvailable": "Geen beschrijving beschikbaar",
|
||||
"noProfilesAvailable": "Geen profielen beschikbaar",
|
||||
"contactAdminForProfiles": "Neem contact op met je Plex-beheerder om profielen toe te voegen",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Kan bibliotheeksectie voor dit item niet bepalen",
|
||||
"logsCleared": "Logs gewist",
|
||||
"logsCopied": "Logs gekopieerd naar klembord",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Bevestig actie"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Plezy-profiel toevoegen",
|
||||
"switchingProfile": "Profiel wisselen…",
|
||||
"deleteThisProfileTitle": "Dit profiel verwijderen?",
|
||||
"deleteThisProfileMessage": "${displayName} wordt verwijderd. Verbindingen worden niet aangetast.",
|
||||
"active": "Actief",
|
||||
"manage": "Beheren",
|
||||
"delete": "Verwijderen",
|
||||
"signOut": "Afmelden",
|
||||
"signOutPlexTitle": "Afmelden bij Plex?",
|
||||
"signOutPlexMessage": "${displayName} en alle Plex Home-gebruikers van dit account worden van dit apparaat verwijderd. Je kunt op elk moment opnieuw inloggen.",
|
||||
"signedOutPlex": "Afgemeld bij Plex.",
|
||||
"signOutFailed": "Afmelden mislukt.",
|
||||
"sectionTitle": "Profielen",
|
||||
"summarySingle": "Voeg profielen toe om beheerde gebruikers en lokale identiteiten te combineren",
|
||||
"summaryMultipleWithActive": "${count} profielen · actief: ${activeName}",
|
||||
"summaryMultiple": "${count} profielen",
|
||||
"removeConnectionTitle": "Verbinding verwijderen?",
|
||||
"removeConnectionMessage": "${displayName} verliest toegang tot ${connectionLabel}. De verbinding blijft beschikbaar voor andere profielen.",
|
||||
"deleteProfileTitle": "Profiel verwijderen?",
|
||||
"deleteProfileMessage": "Hiermee worden ${displayName} en al zijn verbindingen van dit apparaat verwijderd. De onderliggende Plex/Jellyfin-servers worden niet beïnvloed.",
|
||||
"profileNameLabel": "Profielnaam",
|
||||
"pinProtectionLabel": "PIN-beveiliging",
|
||||
"pinManagedByPlex": "PIN wordt beheerd door Plex. Bewerk op plex.tv.",
|
||||
"noPinSetEditOnPlex": "Geen PIN ingesteld. Bewerk de Home-gebruiker op plex.tv om er één te vereisen.",
|
||||
"setPin": "PIN instellen",
|
||||
"connectionsLabel": "Verbindingen",
|
||||
"add": "Toevoegen",
|
||||
"deleteProfileButton": "Profiel verwijderen",
|
||||
"noConnectionsHint": "Geen verbindingen — voeg er één toe om dit profiel te gebruiken.",
|
||||
"plexHomeAccount": "Plex Home-account",
|
||||
"connectionDefault": "Standaard",
|
||||
"makeDefault": "Als standaard instellen",
|
||||
"removeConnection": "Verwijderen",
|
||||
"borrowAddTo": "Toevoegen aan ${displayName}",
|
||||
"borrowExplain": "Leen een verbinding van een ander profiel. PIN-beveiligde bronprofielen vragen om de PIN voordat ze delen.",
|
||||
"borrowEmpty": "Nog niets te lenen.",
|
||||
"borrowEmptySubtitle": "Verbind eerst een Plex-account of Jellyfin-server met een ander profiel en kom dan hier terug.",
|
||||
"newProfile": "Nieuw profiel",
|
||||
"profileNameHint": "bijv. Gasten, Kinderen, Woonkamer",
|
||||
"pinProtectionOptional": "PIN-beveiliging (optioneel)",
|
||||
"pinExplain": "4-cijferige PIN vereist om naar dit profiel te schakelen. Zachte barrière — iedereen die appgegevens kan wissen, kan deze omzeilen.",
|
||||
"continueButton": "Doorgaan",
|
||||
"pinsDontMatch": "PIN-codes komen niet overeen"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Verbindingen",
|
||||
"addConnection": "Verbinding toevoegen",
|
||||
"addConnectionSubtitleNoProfile": "Meld je aan met Plex of verbind een Jellyfin-server",
|
||||
"addConnectionSubtitleScoped": "Toevoegen aan ${displayName} — Plex-account, Jellyfin-server of lenen van een ander profiel",
|
||||
"sessionExpiredOne": "Sessie verlopen voor ${name}",
|
||||
"sessionExpiredMany": "Sessie verlopen voor ${count} servers",
|
||||
"signInAgain": "Opnieuw aanmelden"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Ontdekken",
|
||||
"switchProfile": "Wissel van profiel",
|
||||
"noContentAvailable": "Geen inhoud beschikbaar",
|
||||
"addMediaToLibraries": "Voeg wat media toe aan je bibliotheken",
|
||||
"continueWatching": "Verder kijken",
|
||||
"nextUp": "Volgende",
|
||||
"recentlyAdded": "Recent toegevoegd",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Overzicht",
|
||||
"cast": "Acteurs",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Zoeken mislukt: ${error}",
|
||||
"connectionTimeout": "Verbinding time-out tijdens laden ${context}",
|
||||
"connectionFailed": "Kan geen verbinding maken met Plex server",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Kon ${context} niet laden: ${error}",
|
||||
"noClientAvailable": "Geen client beschikbaar",
|
||||
"authenticationFailed": "Authenticatie mislukt: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Voer een token in",
|
||||
"invalidToken": "Ongeldig token",
|
||||
"failedToVerifyToken": "Kon token niet verifiëren: ${error}",
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}"
|
||||
"failedToSwitchProfile": "Kon niet wisselen naar ${displayName}",
|
||||
"failedToDeleteProfile": "Kon ${displayName} niet verwijderen",
|
||||
"failedToRate": "Beoordeling kon niet worden bijgewerkt"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotheken",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Seizoenen",
|
||||
"episodes": "Afleveringen",
|
||||
"folders": "Mappen"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "Jaar",
|
||||
"contentRating": "Leeftijdsclassificatie",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titel",
|
||||
"dateAdded": "Toegevoegd op",
|
||||
"releaseDate": "Uitgavedatum",
|
||||
"rating": "Beoordeling",
|
||||
"lastPlayed": "Laatst afgespeeld",
|
||||
"playCount": "Aantal afspelingen",
|
||||
"random": "Willekeurig",
|
||||
"dateShared": "Gedeeld op",
|
||||
"latestEpisodeAirDate": "Laatste afleveringsuitzending"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Over",
|
||||
"openSourceLicenses": "Open Source licenties",
|
||||
"versionLabel": "Versie ${version}",
|
||||
"appDescription": "Een mooie Plex client voor Flutter",
|
||||
"appDescription": "Een mooie Plex- en Jellyfin-client voor Flutter",
|
||||
"viewLicensesDescription": "Bekijk licenties van third-party bibliotheken"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Wachten tot anderen geladen zijn...",
|
||||
"recentRooms": "Recente kamers",
|
||||
"renameRoom": "Kamer hernoemen",
|
||||
"removeRoom": "Verwijderen"
|
||||
"removeRoom": "Verwijderen",
|
||||
"guestSwitchUnavailable": "Kon niet schakelen — server niet beschikbaar voor synchronisatie",
|
||||
"guestSwitchFailed": "Kon niet schakelen — inhoud niet gevonden op deze server"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Synchronisatiefilter",
|
||||
"syncAllItems": "Alle items synchroniseren",
|
||||
"syncUnwatchedItems": "Ongekeken items synchroniseren",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Beschikbaar",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Inloggen vereist",
|
||||
"syncRuleNotAvailableForProfile": "Niet beschikbaar voor huidig profiel",
|
||||
"syncRuleUnknownServer": "Onbekende server",
|
||||
"syncRuleListCreated": "Synchronisatieregel aangemaakt"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliotheken",
|
||||
"noLibraries": "Geen bibliotheken beschikbaar"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Jellyfin-server toevoegen",
|
||||
"jellyfinUrlIntro": "Voer de URL van je Jellyfin-server in — bijv. `https://jellyfin.example.com`. Je kunt daarna inloggen.",
|
||||
"serverUrl": "Server-URL",
|
||||
"findServer": "Server zoeken",
|
||||
"username": "Gebruikersnaam",
|
||||
"password": "Wachtwoord",
|
||||
"signIn": "Inloggen",
|
||||
"change": "Wijzigen",
|
||||
"required": "Vereist",
|
||||
"couldNotReachServer": "Kon de server niet bereiken: ${error}",
|
||||
"signInFailed": "Inloggen mislukt: ${error}",
|
||||
"quickConnectFailed": "Quick Connect mislukt: ${error}",
|
||||
"addPlexTitle": "Inloggen met Plex",
|
||||
"plexAuthIntro": "Kies hoe je wilt inloggen bij Plex. De browserflow opent plex.tv waar je de verbinding bevestigt; de QR-optie is handig voor TV / externe apparaten.",
|
||||
"plexQRPrompt": "Scan deze QR-code om in te loggen.",
|
||||
"waitingForPlexConfirmation": "Wachten tot plex.tv je inloggen bevestigt…",
|
||||
"pinExpired": "PIN verlopen vóór inloggen. Probeer opnieuw.",
|
||||
"duplicatePlexAccount": "Dit apparaat is al ingelogd op een Plex-account. Log uit via instellingen om van account te wisselen.",
|
||||
"failedToRegisterAccount": "Account registreren mislukt: ${error}",
|
||||
"enterJellyfinUrlError": "Voer de URL van je Jellyfin-server in",
|
||||
"addConnectionTitle": "Verbinding toevoegen",
|
||||
"addConnectionTitleScoped": "Toevoegen aan ${name}",
|
||||
"addConnectionIntroGlobal": "Voeg nog een mediaserver toe. Je kunt Plex-accounts en Jellyfin-servers combineren — items van alle gekoppelde backends verschijnen samen op het startscherm.",
|
||||
"addConnectionIntroScoped": "Voeg een nieuwe server toe, of leen er een van een ander profiel.",
|
||||
"signInWithPlexCard": "Inloggen met Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoriseer dit apparaat met je Plex-account. Servers gedeeld met het account worden automatisch toegevoegd.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoriseer een nieuw Plex-account. De bijbehorende Home-gebruikers verschijnen als profielen.",
|
||||
"connectToJellyfinCard": "Verbinden met Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Voer de URL van je Jellyfin-server in en log in met gebruikersnaam + wachtwoord (Quick Connect komt eraan).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Log in op een Jellyfin-server. Wordt gekoppeld aan ${name}.",
|
||||
"borrowFromAnotherProfile": "Lenen van een ander profiel",
|
||||
"borrowFromAnotherProfileSubtitle": "Hergebruik een verbinding die al aan een ander profiel is gekoppeld. PIN-beveiligde bronprofielen vragen om de PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Zaloguj się",
|
||||
"signInWithPlex": "Zaloguj się przez Plex",
|
||||
"showQRCode": "Pokaż kod QR",
|
||||
"authenticate": "Uwierzytelnienie",
|
||||
"authenticationTimeout": "Upłynął czas uwierzytelniania. Spróbuj ponownie.",
|
||||
"scanQRToSignIn": "Zeskanuj ten kod QR, aby się zalogować",
|
||||
"waitingForAuth": "Oczekiwanie na uwierzytelnienie...\nDokończ logowanie w przeglądarce.",
|
||||
"useBrowser": "Użyj przeglądarki"
|
||||
"useBrowser": "Użyj przeglądarki",
|
||||
"or": "lub",
|
||||
"connectToJellyfin": "Połącz z Jellyfin",
|
||||
"useQuickConnect": "Użyj Quick Connect",
|
||||
"quickConnectCode": "Kod Quick Connect",
|
||||
"quickConnectInstructions": "Otwórz swój serwer Jellyfin w przeglądarce, zaloguj się i wybierz Quick Connect z menu użytkownika. Wprowadź ten kod, aby zatwierdzić logowanie.",
|
||||
"quickConnectWaiting": "Oczekiwanie na zatwierdzenie…",
|
||||
"quickConnectCancel": "Anuluj",
|
||||
"quickConnectExpired": "Kod Quick Connect wygasł przed zatwierdzeniem. Spróbuj ponownie."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Anuluj",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Siatka",
|
||||
"listView": "Lista",
|
||||
"showHeroSection": "Pokaż sekcję wyróżnioną",
|
||||
"useGlobalHubs": "Użyj układu Plex Home",
|
||||
"useGlobalHubsDescription": "Pokaż huby strony głównej jak w oficjalnym kliencie Plex. Gdy wyłączone, pokazuje rekomendacje per biblioteka.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Pokaż nazwę serwera w hubach",
|
||||
"showServerNameOnHubsDescription": "Zawsze wyświetlaj nazwę serwera w tytułach hubów. Gdy wyłączone, pokazuje tylko dla zduplikowanych nazw.",
|
||||
"groupLibrariesByServer": "Grupuj biblioteki według serwera",
|
||||
"groupLibrariesByServerDescription": "Pokazuj nagłówek dla każdego serwera Plex na pasku bocznym, gdy jesteś połączony z wieloma serwerami.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Zawsze utrzymuj panel boczny otwarty",
|
||||
"alwaysKeepSidebarOpenDescription": "Panel boczny jest rozwinięty, a obszar treści dostosowuje się",
|
||||
"showUnwatchedCount": "Pokaż liczbę nieobejrzanych",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
|
||||
"noDescriptionAvailable": "Brak dostępnego opisu",
|
||||
"noProfilesAvailable": "Brak dostępnych profili",
|
||||
"contactAdminForProfiles": "Skontaktuj się z administratorem Plex, aby dodać profile",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Nie można określić sekcji biblioteki dla tego elementu",
|
||||
"logsCleared": "Logi wyczyszczone",
|
||||
"logsCopied": "Logi skopiowane do schowka",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Potwierdź działanie"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Dodaj profil Plezy",
|
||||
"switchingProfile": "Przełączanie profilu…",
|
||||
"deleteThisProfileTitle": "Usunąć ten profil?",
|
||||
"deleteThisProfileMessage": "${displayName} zostanie usunięty. Połączenia nie zostaną zmienione.",
|
||||
"active": "Aktywny",
|
||||
"manage": "Zarządzaj",
|
||||
"delete": "Usuń",
|
||||
"signOut": "Wyloguj się",
|
||||
"signOutPlexTitle": "Wylogować się z Plex?",
|
||||
"signOutPlexMessage": "${displayName} oraz wszyscy użytkownicy Plex Home na tym koncie zostaną usunięci z tego urządzenia. W każdej chwili możesz się zalogować ponownie.",
|
||||
"signedOutPlex": "Wylogowano z Plex.",
|
||||
"signOutFailed": "Wylogowanie nie powiodło się.",
|
||||
"sectionTitle": "Profile",
|
||||
"summarySingle": "Dodaj profile, aby łączyć zarządzanych użytkowników i tożsamości lokalne",
|
||||
"summaryMultipleWithActive": "${count} profili · aktywny: ${activeName}",
|
||||
"summaryMultiple": "${count} profili",
|
||||
"removeConnectionTitle": "Usunąć połączenie?",
|
||||
"removeConnectionMessage": "${displayName} straci dostęp do ${connectionLabel}. Samo połączenie pozostanie dostępne dla innych profili.",
|
||||
"deleteProfileTitle": "Usunąć profil?",
|
||||
"deleteProfileMessage": "Spowoduje to usunięcie ${displayName} i wszystkich jego połączeń z tego urządzenia. Nie wpłynie to na same serwery Plex/Jellyfin.",
|
||||
"profileNameLabel": "Nazwa profilu",
|
||||
"pinProtectionLabel": "Ochrona PIN-em",
|
||||
"pinManagedByPlex": "PIN zarządzany przez Plex. Edytuj na plex.tv.",
|
||||
"noPinSetEditOnPlex": "Nie ustawiono PIN-u. Aby go wymagać, edytuj użytkownika Home na plex.tv.",
|
||||
"setPin": "Ustaw PIN",
|
||||
"connectionsLabel": "Połączenia",
|
||||
"add": "Dodaj",
|
||||
"deleteProfileButton": "Usuń profil",
|
||||
"noConnectionsHint": "Brak połączeń — dodaj jedno, aby używać tego profilu.",
|
||||
"plexHomeAccount": "Konto Plex Home",
|
||||
"connectionDefault": "Domyślne",
|
||||
"makeDefault": "Ustaw jako domyślne",
|
||||
"removeConnection": "Usuń",
|
||||
"borrowAddTo": "Dodaj do ${displayName}",
|
||||
"borrowExplain": "Pożycz połączenie z innego profilu. Profile źródłowe chronione PIN-em proszą o PIN przed udostępnieniem.",
|
||||
"borrowEmpty": "Nic do pożyczenia.",
|
||||
"borrowEmptySubtitle": "Podłącz najpierw konto Plex lub serwer Jellyfin do innego profilu i wróć tutaj.",
|
||||
"newProfile": "Nowy profil",
|
||||
"profileNameHint": "np. Goście, Dzieci, Salon",
|
||||
"pinProtectionOptional": "Ochrona PIN-em (opcjonalnie)",
|
||||
"pinExplain": "4-cyfrowy PIN wymagany do przełączenia się na ten profil. Miękka bariera — każdy kto może wyczyścić dane aplikacji, może ją obejść.",
|
||||
"continueButton": "Kontynuuj",
|
||||
"pinsDontMatch": "PIN-y nie pasują"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Połączenia",
|
||||
"addConnection": "Dodaj połączenie",
|
||||
"addConnectionSubtitleNoProfile": "Zaloguj się przez Plex lub połącz serwer Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Dodaj do ${displayName} — konto Plex, serwer Jellyfin lub pożycz z innego profilu",
|
||||
"sessionExpiredOne": "Sesja wygasła dla ${name}",
|
||||
"sessionExpiredMany": "Sesja wygasła dla ${count} serwerów",
|
||||
"signInAgain": "Zaloguj się ponownie"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Odkryj",
|
||||
"switchProfile": "Zmień profil",
|
||||
"noContentAvailable": "Brak dostępnych treści",
|
||||
"addMediaToLibraries": "Dodaj multimedia do swoich bibliotek",
|
||||
"continueWatching": "Kontynuuj oglądanie",
|
||||
"nextUp": "Następny odcinek",
|
||||
"recentlyAdded": "Ostatnio dodane",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Opis",
|
||||
"cast": "Obsada",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Wyszukiwanie nie powiodło się: ${error}",
|
||||
"connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}",
|
||||
"connectionFailed": "Nie można połączyć z serwerem Plex",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Nie udało się załadować ${context}: ${error}",
|
||||
"noClientAvailable": "Brak dostępnego klienta",
|
||||
"authenticationFailed": "Uwierzytelnienie nie powiodło się: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Wprowadź token",
|
||||
"invalidToken": "Nieprawidłowy token",
|
||||
"failedToVerifyToken": "Nie udało się zweryfikować tokena: ${error}",
|
||||
"failedToSwitchProfile": "Nie udało się przełączyć na ${displayName}"
|
||||
"failedToSwitchProfile": "Nie udało się przełączyć na ${displayName}",
|
||||
"failedToDeleteProfile": "Nie udało się usunąć ${displayName}",
|
||||
"failedToRate": "Nie udało się zaktualizować oceny"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Biblioteki",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Sezony",
|
||||
"episodes": "Odcinki",
|
||||
"folders": "Foldery"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Gatunek",
|
||||
"year": "Rok",
|
||||
"contentRating": "Klasyfikacja wiekowa",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Tytuł",
|
||||
"dateAdded": "Data dodania",
|
||||
"releaseDate": "Data premiery",
|
||||
"rating": "Ocena",
|
||||
"lastPlayed": "Ostatnio odtwarzane",
|
||||
"playCount": "Liczba odtworzeń",
|
||||
"random": "Losowo",
|
||||
"dateShared": "Data udostępnienia",
|
||||
"latestEpisodeAirDate": "Data emisji ostatniego odcinka"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "O aplikacji",
|
||||
"openSourceLicenses": "Licencje open source",
|
||||
"versionLabel": "Wersja ${version}",
|
||||
"appDescription": "Piękny klient Plex na Flutter",
|
||||
"appDescription": "Piękny klient Plex i Jellyfin na Flutter",
|
||||
"viewLicensesDescription": "Zobacz licencje bibliotek zewnętrznych"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Oczekiwanie na załadowanie u innych...",
|
||||
"recentRooms": "Ostatnie pokoje",
|
||||
"renameRoom": "Zmień nazwę pokoju",
|
||||
"removeRoom": "Usuń"
|
||||
"removeRoom": "Usuń",
|
||||
"guestSwitchUnavailable": "Nie można przełączyć — serwer niedostępny do synchronizacji",
|
||||
"guestSwitchFailed": "Nie można przełączyć — nie znaleziono treści na tym serwerze"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Pobrania",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Filtr synchronizacji",
|
||||
"syncAllItems": "Synchronizuję wszystkie elementy",
|
||||
"syncUnwatchedItems": "Synchronizuję nieobejrzane elementy",
|
||||
"syncRuleServerContext": "Serwer: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Dostępne",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Wymagane logowanie",
|
||||
"syncRuleNotAvailableForProfile": "Niedostępne dla bieżącego profilu",
|
||||
"syncRuleUnknownServer": "Nieznany serwer",
|
||||
"syncRuleListCreated": "Utworzono regułę synchronizacji"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Biblioteki",
|
||||
"noLibraries": "Brak dostępnych bibliotek"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Dodaj serwer Jellyfin",
|
||||
"jellyfinUrlIntro": "Podaj URL serwera Jellyfin — np. `https://jellyfin.example.com`. Możesz się zalogować później.",
|
||||
"serverUrl": "URL serwera",
|
||||
"findServer": "Znajdź serwer",
|
||||
"username": "Nazwa użytkownika",
|
||||
"password": "Hasło",
|
||||
"signIn": "Zaloguj się",
|
||||
"change": "Zmień",
|
||||
"required": "Wymagane",
|
||||
"couldNotReachServer": "Nie udało się połączyć z serwerem: ${error}",
|
||||
"signInFailed": "Logowanie nie powiodło się: ${error}",
|
||||
"quickConnectFailed": "Quick Connect nie powiodło się: ${error}",
|
||||
"addPlexTitle": "Zaloguj się przez Plex",
|
||||
"plexAuthIntro": "Wybierz sposób logowania do Plex. Przeglądarka otwiera plex.tv, gdzie potwierdzasz połączenie; opcja QR jest wygodna dla TV i urządzeń zdalnych.",
|
||||
"plexQRPrompt": "Zeskanuj ten kod QR, aby się zalogować.",
|
||||
"waitingForPlexConfirmation": "Czekam na potwierdzenie logowania przez plex.tv…",
|
||||
"pinExpired": "PIN wygasł przed zalogowaniem. Spróbuj ponownie.",
|
||||
"duplicatePlexAccount": "To urządzenie jest już zalogowane do konta Plex. Wyloguj się w ustawieniach, aby zmienić konto.",
|
||||
"failedToRegisterAccount": "Nie udało się zarejestrować konta: ${error}",
|
||||
"enterJellyfinUrlError": "Podaj URL serwera Jellyfin",
|
||||
"addConnectionTitle": "Dodaj połączenie",
|
||||
"addConnectionTitleScoped": "Dodaj do ${name}",
|
||||
"addConnectionIntroGlobal": "Dodaj kolejny serwer multimediów. Możesz łączyć konta Plex i serwery Jellyfin — zawartość ze wszystkich podłączonych backendów pojawi się razem na ekranie głównym.",
|
||||
"addConnectionIntroScoped": "Dodaj nowy serwer lub pożycz z innego profilu.",
|
||||
"signInWithPlexCard": "Zaloguj się przez Plex",
|
||||
"signInWithPlexCardSubtitle": "Autoryzuj to urządzenie dla swojego konta Plex. Serwery udostępnione kontu zostaną dodane automatycznie.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autoryzuj nowe konto Plex. Jego użytkownicy Home pojawią się jako profile.",
|
||||
"connectToJellyfinCard": "Połącz z Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Podaj URL serwera Jellyfin i zaloguj się nazwą użytkownika + hasłem (Quick Connect wkrótce).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Zaloguj się do serwera Jellyfin. Powiązane z ${name}.",
|
||||
"borrowFromAnotherProfile": "Pożycz z innego profilu",
|
||||
"borrowFromAnotherProfileSubtitle": "Wykorzystaj ponownie połączenie przypisane już do innego profilu. Profile źródłowe chronione PIN-em poproszą o PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Entrar",
|
||||
"signInWithPlex": "Entrar com Plex",
|
||||
"showQRCode": "Mostrar QR Code",
|
||||
"authenticate": "Autenticar",
|
||||
"authenticationTimeout": "A autenticação expirou. Tente novamente.",
|
||||
"scanQRToSignIn": "Escaneie este QR code para entrar",
|
||||
"waitingForAuth": "Aguardando autenticação...\nConclua o login no seu navegador.",
|
||||
"useBrowser": "Usar navegador"
|
||||
"useBrowser": "Usar navegador",
|
||||
"or": "ou",
|
||||
"connectToJellyfin": "Conectar ao Jellyfin",
|
||||
"useQuickConnect": "Usar Quick Connect",
|
||||
"quickConnectCode": "Código do Quick Connect",
|
||||
"quickConnectInstructions": "Abra o seu servidor Jellyfin num navegador, inicie sessão e escolha Quick Connect no menu do utilizador. Insira este código para aprovar o início de sessão.",
|
||||
"quickConnectWaiting": "A aguardar aprovação…",
|
||||
"quickConnectCancel": "Cancelar",
|
||||
"quickConnectExpired": "O código do Quick Connect expirou antes da aprovação. Tente novamente."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancelar",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Grade",
|
||||
"listView": "Lista",
|
||||
"showHeroSection": "Mostrar Seção de Destaque",
|
||||
"useGlobalHubs": "Usar Layout Plex Home",
|
||||
"useGlobalHubsDescription": "Mostrar hubs da página inicial como o cliente oficial Plex. Quando desativado, mostra recomendações por biblioteca.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Mostrar Nome do Servidor nos Hubs",
|
||||
"showServerNameOnHubsDescription": "Sempre exibir o nome do servidor nos títulos dos hubs. Quando desativado, mostra apenas para nomes duplicados.",
|
||||
"groupLibrariesByServer": "Agrupar Bibliotecas por Servidor",
|
||||
"groupLibrariesByServerDescription": "Mostra um cabeçalho para cada servidor Plex na barra lateral quando você está conectado a vários servidores.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Manter Barra Lateral Sempre Aberta",
|
||||
"alwaysKeepSidebarOpenDescription": "A barra lateral fica expandida e a área de conteúdo se ajusta",
|
||||
"showUnwatchedCount": "Mostrar Contagem de Não Assistidos",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Reprodução de música ainda não é suportada",
|
||||
"noDescriptionAvailable": "Nenhuma descrição disponível",
|
||||
"noProfilesAvailable": "Nenhum perfil disponível",
|
||||
"contactAdminForProfiles": "Contacte o seu administrador Plex para adicionar perfis",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Não é possível determinar a secção da biblioteca para este item",
|
||||
"logsCleared": "Logs limpos",
|
||||
"logsCopied": "Logs copiados para a área de transferência",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Confirmar Ação"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Adicionar perfil Plezy",
|
||||
"switchingProfile": "Mudando perfil…",
|
||||
"deleteThisProfileTitle": "Excluir este perfil?",
|
||||
"deleteThisProfileMessage": "${displayName} será removido. As conexões não serão afetadas.",
|
||||
"active": "Ativo",
|
||||
"manage": "Gerenciar",
|
||||
"delete": "Excluir",
|
||||
"signOut": "Sair",
|
||||
"signOutPlexTitle": "Sair do Plex?",
|
||||
"signOutPlexMessage": "${displayName} e todos os usuários do Plex Home desta conta serão removidos deste dispositivo. Você pode entrar novamente a qualquer momento.",
|
||||
"signedOutPlex": "Saiu do Plex.",
|
||||
"signOutFailed": "Falha ao sair.",
|
||||
"sectionTitle": "Perfis",
|
||||
"summarySingle": "Adicione perfis para mesclar usuários gerenciados e identidades locais",
|
||||
"summaryMultipleWithActive": "${count} perfis · ativo: ${activeName}",
|
||||
"summaryMultiple": "${count} perfis",
|
||||
"removeConnectionTitle": "Remover conexão?",
|
||||
"removeConnectionMessage": "${displayName} perderá o acesso a ${connectionLabel}. A conexão em si continua disponível para outros perfis.",
|
||||
"deleteProfileTitle": "Excluir perfil?",
|
||||
"deleteProfileMessage": "Isso remove ${displayName} e todas as suas conexões deste dispositivo. Os servidores Plex/Jellyfin subjacentes não são afetados.",
|
||||
"profileNameLabel": "Nome do perfil",
|
||||
"pinProtectionLabel": "Proteção por PIN",
|
||||
"pinManagedByPlex": "PIN gerenciado pelo Plex. Edite em plex.tv.",
|
||||
"noPinSetEditOnPlex": "Nenhum PIN definido. Para exigir um, edite o usuário Home em plex.tv.",
|
||||
"setPin": "Definir PIN",
|
||||
"connectionsLabel": "Conexões",
|
||||
"add": "Adicionar",
|
||||
"deleteProfileButton": "Excluir perfil",
|
||||
"noConnectionsHint": "Sem conexões — adicione uma para usar este perfil.",
|
||||
"plexHomeAccount": "Conta Plex Home",
|
||||
"connectionDefault": "Padrão",
|
||||
"makeDefault": "Definir como padrão",
|
||||
"removeConnection": "Remover",
|
||||
"borrowAddTo": "Adicionar a ${displayName}",
|
||||
"borrowExplain": "Tome emprestada uma conexão de outro perfil. Perfis de origem protegidos por PIN pedem o PIN antes de compartilhar.",
|
||||
"borrowEmpty": "Nada para emprestar ainda.",
|
||||
"borrowEmptySubtitle": "Conecte primeiro uma conta Plex ou servidor Jellyfin a outro perfil e volte aqui.",
|
||||
"newProfile": "Novo perfil",
|
||||
"profileNameHint": "ex.: Visitantes, Crianças, Sala de família",
|
||||
"pinProtectionOptional": "Proteção por PIN (opcional)",
|
||||
"pinExplain": "PIN de 4 dígitos necessário para alternar para este perfil. Barreira suave — quem puder limpar os dados do app pode contorná-la.",
|
||||
"continueButton": "Continuar",
|
||||
"pinsDontMatch": "Os PINs não correspondem"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Conexões",
|
||||
"addConnection": "Adicionar conexão",
|
||||
"addConnectionSubtitleNoProfile": "Faça login com Plex ou conecte um servidor Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Adicionar a ${displayName} — conta Plex, servidor Jellyfin ou emprestar de outro perfil",
|
||||
"sessionExpiredOne": "Sessão expirada para ${name}",
|
||||
"sessionExpiredMany": "Sessão expirada para ${count} servidores",
|
||||
"signInAgain": "Entrar novamente"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Descobrir",
|
||||
"switchProfile": "Trocar Perfil",
|
||||
"noContentAvailable": "Nenhum conteúdo disponível",
|
||||
"addMediaToLibraries": "Adicione mídias às suas bibliotecas",
|
||||
"continueWatching": "Continuar Assistindo",
|
||||
"nextUp": "A seguir",
|
||||
"recentlyAdded": "Adicionados recentemente",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Sinopse",
|
||||
"cast": "Elenco",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Falha na busca: ${error}",
|
||||
"connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}",
|
||||
"connectionFailed": "Não foi possível conectar ao servidor Plex",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Falha ao carregar ${context}: ${error}",
|
||||
"noClientAvailable": "Nenhum cliente disponível",
|
||||
"authenticationFailed": "Falha na autenticação: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Insira um token",
|
||||
"invalidToken": "Token inválido",
|
||||
"failedToVerifyToken": "Falha ao verificar token: ${error}",
|
||||
"failedToSwitchProfile": "Falha ao trocar para ${displayName}"
|
||||
"failedToSwitchProfile": "Falha ao trocar para ${displayName}",
|
||||
"failedToDeleteProfile": "Falha ao excluir ${displayName}",
|
||||
"failedToRate": "Não foi possível atualizar a classificação"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotecas",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Temporadas",
|
||||
"episodes": "Episódios",
|
||||
"folders": "Pastas"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Gênero",
|
||||
"year": "Ano",
|
||||
"contentRating": "Classificação",
|
||||
"tag": "Tag"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Título",
|
||||
"dateAdded": "Data de adição",
|
||||
"releaseDate": "Data de lançamento",
|
||||
"rating": "Avaliação",
|
||||
"lastPlayed": "Última reprodução",
|
||||
"playCount": "Reproduções",
|
||||
"random": "Aleatório",
|
||||
"dateShared": "Data de compartilhamento",
|
||||
"latestEpisodeAirDate": "Última data de exibição do episódio"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre",
|
||||
"openSourceLicenses": "Licenças Open Source",
|
||||
"versionLabel": "Versão ${version}",
|
||||
"appDescription": "Um belo cliente Plex para Flutter",
|
||||
"appDescription": "Um belo cliente Plex e Jellyfin para Flutter",
|
||||
"viewLicensesDescription": "Ver licenças de bibliotecas de terceiros"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Aguardando outros carregarem...",
|
||||
"recentRooms": "Salas recentes",
|
||||
"renameRoom": "Renomear sala",
|
||||
"removeRoom": "Remover"
|
||||
"removeRoom": "Remover",
|
||||
"guestSwitchUnavailable": "Não foi possível trocar — servidor indisponível para sincronização",
|
||||
"guestSwitchFailed": "Não foi possível trocar — conteúdo não encontrado neste servidor"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Downloads",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Filtro de sincronização",
|
||||
"syncAllItems": "Sincronizando todos os itens",
|
||||
"syncUnwatchedItems": "Sincronizando itens não vistos",
|
||||
"syncRuleServerContext": "Servidor: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Disponível",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Início de sessão necessário",
|
||||
"syncRuleNotAvailableForProfile": "Indisponível para o perfil atual",
|
||||
"syncRuleUnknownServer": "Servidor desconhecido",
|
||||
"syncRuleListCreated": "Regra de sincronização criada"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliotecas",
|
||||
"noLibraries": "Nenhuma biblioteca disponível"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Adicionar servidor Jellyfin",
|
||||
"jellyfinUrlIntro": "Insira a URL do seu servidor Jellyfin — ex.: `https://jellyfin.example.com`. Você pode entrar em seguida.",
|
||||
"serverUrl": "URL do servidor",
|
||||
"findServer": "Encontrar servidor",
|
||||
"username": "Usuário",
|
||||
"password": "Senha",
|
||||
"signIn": "Entrar",
|
||||
"change": "Alterar",
|
||||
"required": "Obrigatório",
|
||||
"couldNotReachServer": "Não foi possível conectar ao servidor: ${error}",
|
||||
"signInFailed": "Falha ao entrar: ${error}",
|
||||
"quickConnectFailed": "Quick Connect falhou: ${error}",
|
||||
"addPlexTitle": "Entrar com Plex",
|
||||
"plexAuthIntro": "Escolha como entrar no Plex. O fluxo do navegador abre plex.tv onde você confirma a conexão; a opção QR é prática para TV / dispositivos remotos.",
|
||||
"plexQRPrompt": "Escaneie este código QR para entrar.",
|
||||
"waitingForPlexConfirmation": "Aguardando o plex.tv confirmar o login…",
|
||||
"pinExpired": "O PIN expirou antes do login. Tente novamente.",
|
||||
"duplicatePlexAccount": "Este dispositivo já está conectado a uma conta Plex. Saia nas configurações para trocar de conta.",
|
||||
"failedToRegisterAccount": "Falha ao registrar a conta: ${error}",
|
||||
"enterJellyfinUrlError": "Insira a URL do seu servidor Jellyfin",
|
||||
"addConnectionTitle": "Adicionar conexão",
|
||||
"addConnectionTitleScoped": "Adicionar a ${name}",
|
||||
"addConnectionIntroGlobal": "Adicione outro servidor de mídia. Você pode misturar contas Plex e servidores Jellyfin — itens de cada backend conectado aparecem juntos na tela inicial.",
|
||||
"addConnectionIntroScoped": "Adicione um novo servidor ou pegue um emprestado de outro perfil.",
|
||||
"signInWithPlexCard": "Entrar com Plex",
|
||||
"signInWithPlexCardSubtitle": "Autorize este dispositivo na sua conta Plex. Servidores compartilhados com a conta vêm junto automaticamente.",
|
||||
"signInWithPlexCardSubtitleScoped": "Autorize uma nova conta Plex. Seus usuários Home aparecem como perfis.",
|
||||
"connectToJellyfinCard": "Conectar ao Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Insira a URL do seu servidor Jellyfin e entre com usuário + senha (Quick Connect em breve).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Entre em um servidor Jellyfin. Vinculado a ${name}.",
|
||||
"borrowFromAnotherProfile": "Pegar emprestado de outro perfil",
|
||||
"borrowFromAnotherProfileSubtitle": "Reutilize uma conexão já associada a outro perfil. Perfis de origem protegidos por PIN solicitarão o PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Войти",
|
||||
"signInWithPlex": "Войти через Plex",
|
||||
"showQRCode": "Показать QR-код",
|
||||
"authenticate": "Аутентификация",
|
||||
"authenticationTimeout": "Время аутентификации истекло. Попробуйте снова.",
|
||||
"scanQRToSignIn": "Отсканируйте QR-код для входа",
|
||||
"waitingForAuth": "Ожидание аутентификации...\nЗавершите вход в браузере.",
|
||||
"useBrowser": "Использовать браузер"
|
||||
"useBrowser": "Использовать браузер",
|
||||
"or": "или",
|
||||
"connectToJellyfin": "Подключиться к Jellyfin",
|
||||
"useQuickConnect": "Использовать Quick Connect",
|
||||
"quickConnectCode": "Код Quick Connect",
|
||||
"quickConnectInstructions": "Откройте сервер Jellyfin в браузере, войдите и выберите Quick Connect в меню пользователя. Введите этот код, чтобы подтвердить вход.",
|
||||
"quickConnectWaiting": "Ожидание подтверждения…",
|
||||
"quickConnectCancel": "Отмена",
|
||||
"quickConnectExpired": "Срок действия кода Quick Connect истёк до подтверждения. Повторите попытку."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Отмена",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Сетка",
|
||||
"listView": "Список",
|
||||
"showHeroSection": "Показать раздел избранного",
|
||||
"useGlobalHubs": "Использовать макет Plex Home",
|
||||
"useGlobalHubsDescription": "Показывать хабы главной страницы как в официальном клиенте Plex. При выключении показывает рекомендации по библиотекам.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Показывать имя сервера в хабах",
|
||||
"showServerNameOnHubsDescription": "Всегда показывать имя сервера в заголовках хабов. При выключении показывает только для дублирующихся имён.",
|
||||
"groupLibrariesByServer": "Группировать библиотеки по серверам",
|
||||
"groupLibrariesByServerDescription": "Показывать заголовок для каждого сервера Plex на боковой панели при подключении к нескольким серверам.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Всегда держать боковую панель открытой",
|
||||
"alwaysKeepSidebarOpenDescription": "Боковая панель остаётся развёрнутой, область контента подстраивается",
|
||||
"showUnwatchedCount": "Показывать количество непросмотренных",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
|
||||
"noDescriptionAvailable": "Описание недоступно",
|
||||
"noProfilesAvailable": "Профили недоступны",
|
||||
"contactAdminForProfiles": "Обратитесь к администратору Plex, чтобы добавить профили",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Не удаётся определить раздел библиотеки для этого элемента",
|
||||
"logsCleared": "Логи очищены",
|
||||
"logsCopied": "Логи скопированы в буфер обмена",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Подтвердить действие"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Добавить профиль Plezy",
|
||||
"switchingProfile": "Переключение профиля…",
|
||||
"deleteThisProfileTitle": "Удалить этот профиль?",
|
||||
"deleteThisProfileMessage": "${displayName} будет удалён. Подключения не пострадают.",
|
||||
"active": "Активный",
|
||||
"manage": "Управление",
|
||||
"delete": "Удалить",
|
||||
"signOut": "Выйти",
|
||||
"signOutPlexTitle": "Выйти из Plex?",
|
||||
"signOutPlexMessage": "${displayName} и все пользователи Plex Home этой учётной записи будут удалены с этого устройства. Вы можете войти снова в любое время.",
|
||||
"signedOutPlex": "Вы вышли из Plex.",
|
||||
"signOutFailed": "Не удалось выйти.",
|
||||
"sectionTitle": "Профили",
|
||||
"summarySingle": "Добавьте профили, чтобы смешать управляемых пользователей и локальные идентификаторы",
|
||||
"summaryMultipleWithActive": "${count} профилей · активный: ${activeName}",
|
||||
"summaryMultiple": "${count} профилей",
|
||||
"removeConnectionTitle": "Удалить соединение?",
|
||||
"removeConnectionMessage": "${displayName} потеряет доступ к ${connectionLabel}. Само соединение останется доступным для других профилей.",
|
||||
"deleteProfileTitle": "Удалить профиль?",
|
||||
"deleteProfileMessage": "Это удалит ${displayName} и все его соединения с этого устройства. Сами серверы Plex/Jellyfin не будут затронуты.",
|
||||
"profileNameLabel": "Имя профиля",
|
||||
"pinProtectionLabel": "Защита PIN-кодом",
|
||||
"pinManagedByPlex": "PIN управляется Plex. Редактируйте на plex.tv.",
|
||||
"noPinSetEditOnPlex": "PIN не установлен. Чтобы требовать его, отредактируйте пользователя Home на plex.tv.",
|
||||
"setPin": "Установить PIN",
|
||||
"connectionsLabel": "Соединения",
|
||||
"add": "Добавить",
|
||||
"deleteProfileButton": "Удалить профиль",
|
||||
"noConnectionsHint": "Нет соединений — добавьте одно, чтобы использовать этот профиль.",
|
||||
"plexHomeAccount": "Аккаунт Plex Home",
|
||||
"connectionDefault": "По умолчанию",
|
||||
"makeDefault": "Сделать по умолчанию",
|
||||
"removeConnection": "Удалить",
|
||||
"borrowAddTo": "Добавить в ${displayName}",
|
||||
"borrowExplain": "Заимствуйте соединение из другого профиля. Защищённые PIN-кодом исходные профили запросят PIN перед предоставлением доступа.",
|
||||
"borrowEmpty": "Пока нечего заимствовать.",
|
||||
"borrowEmptySubtitle": "Сначала подключите аккаунт Plex или сервер Jellyfin к другому профилю, а затем вернитесь сюда.",
|
||||
"newProfile": "Новый профиль",
|
||||
"profileNameHint": "например, Гости, Дети, Семейная комната",
|
||||
"pinProtectionOptional": "Защита PIN-кодом (необязательно)",
|
||||
"pinExplain": "Для переключения на этот профиль требуется 4-значный PIN. Мягкий барьер — любой, кто может очистить данные приложения, может его обойти.",
|
||||
"continueButton": "Продолжить",
|
||||
"pinsDontMatch": "PIN-коды не совпадают"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Подключения",
|
||||
"addConnection": "Добавить подключение",
|
||||
"addConnectionSubtitleNoProfile": "Войдите через Plex или подключите сервер Jellyfin",
|
||||
"addConnectionSubtitleScoped": "Добавить к ${displayName} — учётная запись Plex, сервер Jellyfin или заимствовать из другого профиля",
|
||||
"sessionExpiredOne": "Сессия истекла для ${name}",
|
||||
"sessionExpiredMany": "Сессия истекла для ${count} серверов",
|
||||
"signInAgain": "Войти снова"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Обзор",
|
||||
"switchProfile": "Сменить профиль",
|
||||
"noContentAvailable": "Контент недоступен",
|
||||
"addMediaToLibraries": "Добавьте медиафайлы в ваши библиотеки",
|
||||
"continueWatching": "Продолжить просмотр",
|
||||
"nextUp": "Далее",
|
||||
"recentlyAdded": "Недавно добавленное",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Обзор",
|
||||
"cast": "В ролях",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Ошибка поиска: ${error}",
|
||||
"connectionTimeout": "Таймаут подключения при загрузке ${context}",
|
||||
"connectionFailed": "Не удаётся подключиться к серверу Plex",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Не удалось загрузить ${context}: ${error}",
|
||||
"noClientAvailable": "Клиент недоступен",
|
||||
"authenticationFailed": "Ошибка аутентификации: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Введите токен",
|
||||
"invalidToken": "Недействительный токен",
|
||||
"failedToVerifyToken": "Не удалось проверить токен: ${error}",
|
||||
"failedToSwitchProfile": "Не удалось переключиться на ${displayName}"
|
||||
"failedToSwitchProfile": "Не удалось переключиться на ${displayName}",
|
||||
"failedToDeleteProfile": "Не удалось удалить ${displayName}",
|
||||
"failedToRate": "Не удалось обновить оценку"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Библиотеки",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Сезоны",
|
||||
"episodes": "Эпизоды",
|
||||
"folders": "Папки"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Жанр",
|
||||
"year": "Год",
|
||||
"contentRating": "Возрастной рейтинг",
|
||||
"tag": "Тег"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Название",
|
||||
"dateAdded": "Дата добавления",
|
||||
"releaseDate": "Дата выхода",
|
||||
"rating": "Рейтинг",
|
||||
"lastPlayed": "Последний просмотр",
|
||||
"playCount": "Количество просмотров",
|
||||
"random": "Случайно",
|
||||
"dateShared": "Дата открытия доступа",
|
||||
"latestEpisodeAirDate": "Дата выхода последнего эпизода"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "О приложении",
|
||||
"openSourceLicenses": "Лицензии открытого ПО",
|
||||
"versionLabel": "Версия ${version}",
|
||||
"appDescription": "Красивый клиент Plex на Flutter",
|
||||
"appDescription": "Красивый клиент Plex и Jellyfin на Flutter",
|
||||
"viewLicensesDescription": "Просмотр лицензий сторонних библиотек"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Ожидание загрузки у других...",
|
||||
"recentRooms": "Недавние комнаты",
|
||||
"renameRoom": "Переименовать комнату",
|
||||
"removeRoom": "Удалить"
|
||||
"removeRoom": "Удалить",
|
||||
"guestSwitchUnavailable": "Не удалось переключиться — сервер недоступен для синхронизации",
|
||||
"guestSwitchFailed": "Не удалось переключиться — содержимое не найдено на этом сервере"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Загрузки",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Фильтр синхронизации",
|
||||
"syncAllItems": "Синхронизация всех элементов",
|
||||
"syncUnwatchedItems": "Синхронизация непросмотренных элементов",
|
||||
"syncRuleServerContext": "Сервер: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Доступен",
|
||||
"syncRuleOffline": "Офлайн",
|
||||
"syncRuleSignInRequired": "Требуется вход",
|
||||
"syncRuleNotAvailableForProfile": "Недоступно для текущего профиля",
|
||||
"syncRuleUnknownServer": "Неизвестный сервер",
|
||||
"syncRuleListCreated": "Правило синхронизации создано"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Библиотеки",
|
||||
"noLibraries": "Библиотеки недоступны"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Добавить сервер Jellyfin",
|
||||
"jellyfinUrlIntro": "Введите URL вашего сервера Jellyfin — например, `https://jellyfin.example.com`. Войти можно после.",
|
||||
"serverUrl": "URL сервера",
|
||||
"findServer": "Найти сервер",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"change": "Изменить",
|
||||
"required": "Обязательно",
|
||||
"couldNotReachServer": "Не удалось связаться с сервером: ${error}",
|
||||
"signInFailed": "Не удалось войти: ${error}",
|
||||
"quickConnectFailed": "Quick Connect не удался: ${error}",
|
||||
"addPlexTitle": "Войти через Plex",
|
||||
"plexAuthIntro": "Выберите способ входа в Plex. Поток в браузере открывает plex.tv, где вы подтверждаете подключение; QR-вариант удобен для TV или удалённых устройств.",
|
||||
"plexQRPrompt": "Отсканируйте этот QR-код, чтобы войти.",
|
||||
"waitingForPlexConfirmation": "Ожидание подтверждения от plex.tv…",
|
||||
"pinExpired": "Срок действия PIN истёк до входа. Попробуйте снова.",
|
||||
"duplicatePlexAccount": "Это устройство уже подключено к учётной записи Plex. Выйдите в настройках, чтобы сменить учётную запись.",
|
||||
"failedToRegisterAccount": "Не удалось зарегистрировать учётную запись: ${error}",
|
||||
"enterJellyfinUrlError": "Введите URL вашего сервера Jellyfin",
|
||||
"addConnectionTitle": "Добавить подключение",
|
||||
"addConnectionTitleScoped": "Добавить в ${name}",
|
||||
"addConnectionIntroGlobal": "Добавьте ещё один медиасервер. Можно сочетать учётные записи Plex и серверы Jellyfin — элементы со всех подключённых бэкендов появятся вместе на главном экране.",
|
||||
"addConnectionIntroScoped": "Добавьте новый сервер или одолжите из другого профиля.",
|
||||
"signInWithPlexCard": "Войти через Plex",
|
||||
"signInWithPlexCardSubtitle": "Авторизуйте это устройство в вашей учётной записи Plex. Серверы, общие с учётной записью, добавятся автоматически.",
|
||||
"signInWithPlexCardSubtitleScoped": "Авторизуйте новую учётную запись Plex. Её Home-пользователи появятся как профили.",
|
||||
"connectToJellyfinCard": "Подключиться к Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Введите URL сервера Jellyfin и войдите с именем пользователя и паролем (Quick Connect — скоро).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Войдите на сервер Jellyfin. Привязывается к ${name}.",
|
||||
"borrowFromAnotherProfile": "Одолжить из другого профиля",
|
||||
"borrowFromAnotherProfileSubtitle": "Повторно используйте подключение, уже привязанное к другому профилю. PIN-защищённые исходные профили запрашивают PIN."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 15
|
||||
/// Strings: 14625 (975 per locale)
|
||||
/// Strings: 16365 (1091 per locale)
|
||||
///
|
||||
/// Built on 2026-04-27 at 13:13 UTC
|
||||
/// Built on 2026-04-30 at 17:27 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+640
-124
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+407
-121
File diff suppressed because it is too large
Load Diff
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Logga in",
|
||||
"signInWithPlex": "Logga in med Plex",
|
||||
"showQRCode": "Visa QR-kod",
|
||||
"authenticate": "Autentisera",
|
||||
"authenticationTimeout": "Autentisering tog för lång tid. Försök igen.",
|
||||
"scanQRToSignIn": "Skanna QR-koden för att logga in",
|
||||
"waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare.",
|
||||
"useBrowser": "Använd webbläsare"
|
||||
"useBrowser": "Använd webbläsare",
|
||||
"or": "eller",
|
||||
"connectToJellyfin": "Anslut till Jellyfin",
|
||||
"useQuickConnect": "Använd Quick Connect",
|
||||
"quickConnectCode": "Quick Connect-kod",
|
||||
"quickConnectInstructions": "Öppna din Jellyfin-server i en webbläsare, logga in och välj Quick Connect i användarmenyn. Ange denna kod för att godkänna inloggningen.",
|
||||
"quickConnectWaiting": "Väntar på godkännande…",
|
||||
"quickConnectCancel": "Avbryt",
|
||||
"quickConnectExpired": "Quick Connect-koden gick ut innan den godkändes. Försök igen."
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Avbryt",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "Rutnät",
|
||||
"listView": "Lista",
|
||||
"showHeroSection": "Visa hjältesektion",
|
||||
"useGlobalHubs": "Använd Plex hem-layout",
|
||||
"useGlobalHubsDescription": "Visar startsidans hubbar som den officiella Plex-klienten. När av visas rekommendationer per bibliotek istället.",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "Visa servernamn på hubbar",
|
||||
"showServerNameOnHubsDescription": "Visa alltid servernamnet i hubbtitlar. När av visas endast för duplicerade hubbnamn.",
|
||||
"groupLibrariesByServer": "Gruppera bibliotek efter server",
|
||||
"groupLibrariesByServerDescription": "Visa en rubrik för varje Plex-server i sidofältet när du är ansluten till flera servrar.",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "Håll sidofältet alltid öppet",
|
||||
"alwaysKeepSidebarOpenDescription": "Sidofältet förblir expanderat och innehållsytan anpassas",
|
||||
"showUnwatchedCount": "Visa antal osedda",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "Musikuppspelning stöds inte ännu",
|
||||
"noDescriptionAvailable": "Ingen beskrivning tillgänglig",
|
||||
"noProfilesAvailable": "Inga profiler tillgängliga",
|
||||
"contactAdminForProfiles": "Kontakta din Plex-administratör för att lägga till profiler",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "Kan inte avgöra biblioteksavdelningen för detta objekt",
|
||||
"logsCleared": "Loggar rensade",
|
||||
"logsCopied": "Loggar kopierade till urklipp",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "Bekräfta åtgärd"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "Lägg till Plezy-profil",
|
||||
"switchingProfile": "Byter profil…",
|
||||
"deleteThisProfileTitle": "Ta bort denna profil?",
|
||||
"deleteThisProfileMessage": "${displayName} tas bort. Anslutningar påverkas inte.",
|
||||
"active": "Aktiv",
|
||||
"manage": "Hantera",
|
||||
"delete": "Ta bort",
|
||||
"signOut": "Logga ut",
|
||||
"signOutPlexTitle": "Logga ut från Plex?",
|
||||
"signOutPlexMessage": "${displayName} och alla Plex Home-användare på det här kontot tas bort från den här enheten. Du kan logga in igen när som helst.",
|
||||
"signedOutPlex": "Utloggad från Plex.",
|
||||
"signOutFailed": "Utloggningen misslyckades.",
|
||||
"sectionTitle": "Profiler",
|
||||
"summarySingle": "Lägg till profiler för att blanda hanterade användare och lokala identiteter",
|
||||
"summaryMultipleWithActive": "${count} profiler · aktiv: ${activeName}",
|
||||
"summaryMultiple": "${count} profiler",
|
||||
"removeConnectionTitle": "Ta bort anslutning?",
|
||||
"removeConnectionMessage": "${displayName} förlorar tillgång till ${connectionLabel}. Anslutningen är fortfarande tillgänglig för andra profiler.",
|
||||
"deleteProfileTitle": "Ta bort profil?",
|
||||
"deleteProfileMessage": "Detta tar bort ${displayName} och alla dess anslutningar från den här enheten. De underliggande Plex/Jellyfin-servrarna påverkas inte.",
|
||||
"profileNameLabel": "Profilnamn",
|
||||
"pinProtectionLabel": "PIN-skydd",
|
||||
"pinManagedByPlex": "PIN hanteras av Plex. Redigera på plex.tv.",
|
||||
"noPinSetEditOnPlex": "Ingen PIN angiven. För att kräva en, redigera Home-användaren på plex.tv.",
|
||||
"setPin": "Ange PIN",
|
||||
"connectionsLabel": "Anslutningar",
|
||||
"add": "Lägg till",
|
||||
"deleteProfileButton": "Ta bort profil",
|
||||
"noConnectionsHint": "Inga anslutningar — lägg till en för att använda den här profilen.",
|
||||
"plexHomeAccount": "Plex Home-konto",
|
||||
"connectionDefault": "Standard",
|
||||
"makeDefault": "Gör till standard",
|
||||
"removeConnection": "Ta bort",
|
||||
"borrowAddTo": "Lägg till i ${displayName}",
|
||||
"borrowExplain": "Låna en anslutning från en annan profil. PIN-skyddade källprofiler ber om PIN före delning.",
|
||||
"borrowEmpty": "Inget att låna ännu.",
|
||||
"borrowEmptySubtitle": "Anslut först ett Plex-konto eller en Jellyfin-server till en annan profil och kom sedan tillbaka hit.",
|
||||
"newProfile": "Ny profil",
|
||||
"profileNameHint": "t.ex. Gäster, Barn, Familjerum",
|
||||
"pinProtectionOptional": "PIN-skydd (valfritt)",
|
||||
"pinExplain": "4-siffrig PIN krävs för att växla till den här profilen. Mjuk barriär — vem som helst som kan rensa appdata kan kringgå den.",
|
||||
"continueButton": "Fortsätt",
|
||||
"pinsDontMatch": "PIN-koderna stämmer inte överens"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "Anslutningar",
|
||||
"addConnection": "Lägg till anslutning",
|
||||
"addConnectionSubtitleNoProfile": "Logga in med Plex eller anslut en Jellyfin-server",
|
||||
"addConnectionSubtitleScoped": "Lägg till ${displayName} — Plex-konto, Jellyfin-server eller låna från en annan profil",
|
||||
"sessionExpiredOne": "Sessionen har gått ut för ${name}",
|
||||
"sessionExpiredMany": "Sessionen har gått ut för ${count} servrar",
|
||||
"signInAgain": "Logga in igen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Upptäck",
|
||||
"switchProfile": "Byt profil",
|
||||
"noContentAvailable": "Inget innehåll tillgängligt",
|
||||
"addMediaToLibraries": "Lägg till media till dina bibliotek",
|
||||
"continueWatching": "Fortsätt titta",
|
||||
"nextUp": "Nästa",
|
||||
"recentlyAdded": "Nyligen tillagda",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "Översikt",
|
||||
"cast": "Rollbesättning",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "Sökning misslyckades: ${error}",
|
||||
"connectionTimeout": "Anslutnings-timeout vid laddning ${context}",
|
||||
"connectionFailed": "Kan inte ansluta till Plex-server",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "Misslyckades att ladda ${context}: ${error}",
|
||||
"noClientAvailable": "Ingen klient tillgänglig",
|
||||
"authenticationFailed": "Autentisering misslyckades: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "Vänligen ange en token",
|
||||
"invalidToken": "Ogiltig token",
|
||||
"failedToVerifyToken": "Misslyckades att verifiera token: ${error}",
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}"
|
||||
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
|
||||
"failedToDeleteProfile": "Misslyckades att ta bort ${displayName}",
|
||||
"failedToRate": "Det gick inte att uppdatera betyget"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "Bibliotek",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "Säsonger",
|
||||
"episodes": "Avsnitt",
|
||||
"folders": "Mappar"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "Genre",
|
||||
"year": "År",
|
||||
"contentRating": "Åldersgräns",
|
||||
"tag": "Tagg"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "Titel",
|
||||
"dateAdded": "Tillagd",
|
||||
"releaseDate": "Releasedatum",
|
||||
"rating": "Betyg",
|
||||
"lastPlayed": "Senast spelad",
|
||||
"playCount": "Antal spelningar",
|
||||
"random": "Slumpmässigt",
|
||||
"dateShared": "Delningsdatum",
|
||||
"latestEpisodeAirDate": "Senaste avsnittets sändningsdatum"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Om",
|
||||
"openSourceLicenses": "Öppen källkod-licenser",
|
||||
"versionLabel": "Version ${version}",
|
||||
"appDescription": "En vacker Plex-klient för Flutter",
|
||||
"appDescription": "En vacker Plex- och Jellyfin-klient för Flutter",
|
||||
"viewLicensesDescription": "Visa licenser för tredjepartsbibliotek"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "Väntar på att andra laddar...",
|
||||
"recentRooms": "Senaste rum",
|
||||
"renameRoom": "Byt namn på rum",
|
||||
"removeRoom": "Ta bort"
|
||||
"removeRoom": "Ta bort",
|
||||
"guestSwitchUnavailable": "Kunde inte byta — server inte tillgänglig för synkronisering",
|
||||
"guestSwitchFailed": "Kunde inte byta — innehåll hittades inte på denna server"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "Nedladdningar",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "Synkroniseringsfilter",
|
||||
"syncAllItems": "Synkroniserar alla objekt",
|
||||
"syncUnwatchedItems": "Synkroniserar osedda objekt",
|
||||
"syncRuleServerContext": "Server: ${server} • ${status}",
|
||||
"syncRuleAvailable": "Tillgänglig",
|
||||
"syncRuleOffline": "Offline",
|
||||
"syncRuleSignInRequired": "Inloggning krävs",
|
||||
"syncRuleNotAvailableForProfile": "Inte tillgänglig för aktuell profil",
|
||||
"syncRuleUnknownServer": "Okänd server",
|
||||
"syncRuleListCreated": "Synkroniseringsregel skapad"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "Bibliotek",
|
||||
"noLibraries": "Inga bibliotek tillgängliga"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "Lägg till Jellyfin-server",
|
||||
"jellyfinUrlIntro": "Ange URL till din Jellyfin-server — t.ex. `https://jellyfin.example.com`. Du kan logga in efteråt.",
|
||||
"serverUrl": "Server-URL",
|
||||
"findServer": "Hitta server",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"signIn": "Logga in",
|
||||
"change": "Ändra",
|
||||
"required": "Krävs",
|
||||
"couldNotReachServer": "Kunde inte nå servern: ${error}",
|
||||
"signInFailed": "Inloggning misslyckades: ${error}",
|
||||
"quickConnectFailed": "Quick Connect misslyckades: ${error}",
|
||||
"addPlexTitle": "Logga in med Plex",
|
||||
"plexAuthIntro": "Välj hur du vill logga in på Plex. Webbläsarflödet öppnar plex.tv där du bekräftar anslutningen; QR-alternativet är praktiskt för TV / fjärrenheter.",
|
||||
"plexQRPrompt": "Skanna denna QR-kod för att logga in.",
|
||||
"waitingForPlexConfirmation": "Väntar på att plex.tv ska bekräfta inloggningen…",
|
||||
"pinExpired": "PIN-koden gick ut innan inloggning. Försök igen.",
|
||||
"duplicatePlexAccount": "Den här enheten är redan inloggad på ett Plex-konto. Logga ut från inställningarna för att byta konto.",
|
||||
"failedToRegisterAccount": "Kunde inte registrera kontot: ${error}",
|
||||
"enterJellyfinUrlError": "Ange URL till din Jellyfin-server",
|
||||
"addConnectionTitle": "Lägg till anslutning",
|
||||
"addConnectionTitleScoped": "Lägg till i ${name}",
|
||||
"addConnectionIntroGlobal": "Lägg till ytterligare en medieserver. Du kan blanda Plex-konton och Jellyfin-servrar — innehåll från alla anslutna backends visas tillsammans på startskärmen.",
|
||||
"addConnectionIntroScoped": "Lägg till en ny server, eller låna en från en annan profil.",
|
||||
"signInWithPlexCard": "Logga in med Plex",
|
||||
"signInWithPlexCardSubtitle": "Auktorisera den här enheten mot ditt Plex-konto. Servrar delade med kontot följer med automatiskt.",
|
||||
"signInWithPlexCardSubtitleScoped": "Auktorisera ett nytt Plex-konto. Dess Home-användare visas som profiler.",
|
||||
"connectToJellyfinCard": "Anslut till Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "Ange URL till din Jellyfin-server och logga in med användarnamn + lösenord (Quick Connect kommer snart).",
|
||||
"connectToJellyfinCardSubtitleScoped": "Logga in på en Jellyfin-server. Kopplas till ${name}.",
|
||||
"borrowFromAnotherProfile": "Låna från en annan profil",
|
||||
"borrowFromAnotherProfileSubtitle": "Återanvänd en anslutning som redan är kopplad till en annan profil. PIN-skyddade källprofiler ber om PIN."
|
||||
}
|
||||
}
|
||||
|
||||
+135
-9
@@ -3,13 +3,22 @@
|
||||
"title": "Plezy"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "登录",
|
||||
"signInWithPlex": "使用 Plex 登录",
|
||||
"showQRCode": "显示二维码",
|
||||
"authenticate": "验证",
|
||||
"authenticationTimeout": "验证超时。请重试。",
|
||||
"scanQRToSignIn": "扫描二维码登录",
|
||||
"waitingForAuth": "等待验证中...\n请在你的浏览器中完成登录。",
|
||||
"useBrowser": "使用浏览器"
|
||||
"useBrowser": "使用浏览器",
|
||||
"or": "或",
|
||||
"connectToJellyfin": "连接到 Jellyfin",
|
||||
"useQuickConnect": "使用 Quick Connect",
|
||||
"quickConnectCode": "Quick Connect 代码",
|
||||
"quickConnectInstructions": "在浏览器中打开你的 Jellyfin 服务器,登录后从用户菜单中选择 Quick Connect。输入此代码以批准登录。",
|
||||
"quickConnectWaiting": "等待批准…",
|
||||
"quickConnectCancel": "取消",
|
||||
"quickConnectExpired": "Quick Connect 代码在批准前已过期。请重试。"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "取消",
|
||||
@@ -104,12 +113,12 @@
|
||||
"gridView": "网格视图",
|
||||
"listView": "列表视图",
|
||||
"showHeroSection": "显示主要精选区",
|
||||
"useGlobalHubs": "使用 Plex 主页布局",
|
||||
"useGlobalHubsDescription": "显示与官方 Plex 客户端相同的主页推荐。关闭时将显示按媒体库分类的推荐。",
|
||||
"useGlobalHubs": "Use Home Layout",
|
||||
"useGlobalHubsDescription": "Show home page hubs like the official client. When off, shows per-library recommendations instead.",
|
||||
"showServerNameOnHubs": "在推荐栏显示服务器名称",
|
||||
"showServerNameOnHubsDescription": "始终在推荐栏标题中显示服务器名称。关闭时仅在推荐栏名称重复时显示。",
|
||||
"groupLibrariesByServer": "按服务器分组媒体库",
|
||||
"groupLibrariesByServerDescription": "当您连接到多个服务器时,在侧边栏中为每个 Plex 服务器显示一个标题。",
|
||||
"groupLibrariesByServerDescription": "Show a header for each media server in the sidebar when you're connected to multiple servers.",
|
||||
"alwaysKeepSidebarOpen": "始终保持侧边栏展开",
|
||||
"alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整",
|
||||
"showUnwatchedCount": "显示未观看数量",
|
||||
@@ -452,7 +461,7 @@
|
||||
"musicNotSupported": "尚不支持播放音乐",
|
||||
"noDescriptionAvailable": "暂无描述",
|
||||
"noProfilesAvailable": "没有可用的用户",
|
||||
"contactAdminForProfiles": "请联系您的 Plex 管理员添加用户",
|
||||
"contactAdminForProfiles": "Contact your server administrator to add profiles",
|
||||
"unableToDetermineLibrarySection": "无法确定此项目的库分区",
|
||||
"logsCleared": "日志已清除",
|
||||
"logsCopied": "日志已复制到剪贴板",
|
||||
@@ -513,12 +522,68 @@
|
||||
"dialog": {
|
||||
"confirmAction": "确认操作"
|
||||
},
|
||||
"profiles": {
|
||||
"addPlezyProfile": "添加 Plezy 配置文件",
|
||||
"switchingProfile": "切换配置文件中…",
|
||||
"deleteThisProfileTitle": "删除此配置文件?",
|
||||
"deleteThisProfileMessage": "${displayName} 将被移除。连接本身不受影响。",
|
||||
"active": "活跃",
|
||||
"manage": "管理",
|
||||
"delete": "删除",
|
||||
"signOut": "退出登录",
|
||||
"signOutPlexTitle": "退出 Plex 登录?",
|
||||
"signOutPlexMessage": "${displayName} 以及该账户下的所有 Plex Home 用户将从此设备移除。您可以随时重新登录。",
|
||||
"signedOutPlex": "已退出 Plex 登录。",
|
||||
"signOutFailed": "退出登录失败。",
|
||||
"sectionTitle": "配置文件",
|
||||
"summarySingle": "添加配置文件以混合托管用户和本地身份",
|
||||
"summaryMultipleWithActive": "${count} 个配置文件 · 活跃:${activeName}",
|
||||
"summaryMultiple": "${count} 个配置文件",
|
||||
"removeConnectionTitle": "移除连接?",
|
||||
"removeConnectionMessage": "${displayName} 将失去对 ${connectionLabel} 的访问权限。连接本身仍可供其他配置文件使用。",
|
||||
"deleteProfileTitle": "删除配置文件?",
|
||||
"deleteProfileMessage": "这将从此设备中删除 ${displayName} 及其所有连接。底层的 Plex/Jellyfin 服务器不会受到影响。",
|
||||
"profileNameLabel": "配置文件名称",
|
||||
"pinProtectionLabel": "PIN 保护",
|
||||
"pinManagedByPlex": "PIN 由 Plex 管理。在 plex.tv 上编辑。",
|
||||
"noPinSetEditOnPlex": "未设置 PIN。如需要求 PIN,请在 plex.tv 上编辑 Home 用户。",
|
||||
"setPin": "设置 PIN",
|
||||
"connectionsLabel": "连接",
|
||||
"add": "添加",
|
||||
"deleteProfileButton": "删除配置文件",
|
||||
"noConnectionsHint": "没有连接 — 添加一个以使用此配置文件。",
|
||||
"plexHomeAccount": "Plex Home 账户",
|
||||
"connectionDefault": "默认",
|
||||
"makeDefault": "设为默认",
|
||||
"removeConnection": "移除",
|
||||
"borrowAddTo": "添加到 ${displayName}",
|
||||
"borrowExplain": "从另一个配置文件借用连接。PIN 保护的源配置文件在共享前会要求输入 PIN。",
|
||||
"borrowEmpty": "暂无可借用的内容。",
|
||||
"borrowEmptySubtitle": "请先将 Plex 账户或 Jellyfin 服务器连接到另一个配置文件,然后回到这里。",
|
||||
"newProfile": "新建配置文件",
|
||||
"profileNameHint": "例如:访客、儿童、家庭房",
|
||||
"pinProtectionOptional": "PIN 保护(可选)",
|
||||
"pinExplain": "切换到此配置文件需要 4 位 PIN。软屏障 — 任何能清除应用数据的人都可以绕过它。",
|
||||
"continueButton": "继续",
|
||||
"pinsDontMatch": "PIN 不匹配"
|
||||
},
|
||||
"connections": {
|
||||
"sectionTitle": "连接",
|
||||
"addConnection": "添加连接",
|
||||
"addConnectionSubtitleNoProfile": "使用 Plex 登录或连接 Jellyfin 服务器",
|
||||
"addConnectionSubtitleScoped": "添加到 ${displayName} — Plex 帐户、Jellyfin 服务器或从其他配置文件借用",
|
||||
"sessionExpiredOne": "${name} 的会话已过期",
|
||||
"sessionExpiredMany": "${count} 个服务器的会话已过期",
|
||||
"signInAgain": "重新登录"
|
||||
},
|
||||
"discover": {
|
||||
"title": "发现",
|
||||
"switchProfile": "切换用户",
|
||||
"noContentAvailable": "没有可用内容",
|
||||
"addMediaToLibraries": "请向你的媒体库添加一些媒体",
|
||||
"continueWatching": "继续观看",
|
||||
"nextUp": "接下来",
|
||||
"recentlyAdded": "最近添加",
|
||||
"playEpisode": "S${season}E${episode}",
|
||||
"overview": "概述",
|
||||
"cast": "演员表",
|
||||
@@ -532,7 +597,7 @@
|
||||
"errors": {
|
||||
"searchFailed": "搜索失败: ${error}",
|
||||
"connectionTimeout": "加载 ${context} 时连接超时",
|
||||
"connectionFailed": "无法连接到 Plex 服务器",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"failedToLoad": "无法加载 ${context}: ${error}",
|
||||
"noClientAvailable": "没有可用客户端",
|
||||
"authenticationFailed": "验证失败: ${error}",
|
||||
@@ -540,7 +605,9 @@
|
||||
"pleaseEnterToken": "请输入一个令牌",
|
||||
"invalidToken": "令牌无效",
|
||||
"failedToVerifyToken": "无法验证令牌: ${error}",
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}"
|
||||
"failedToSwitchProfile": "无法切换到 ${displayName}",
|
||||
"failedToDeleteProfile": "无法删除 ${displayName}",
|
||||
"failedToRate": "无法更新评分"
|
||||
},
|
||||
"libraries": {
|
||||
"title": "媒体库",
|
||||
@@ -595,13 +662,30 @@
|
||||
"seasons": "季",
|
||||
"episodes": "集",
|
||||
"folders": "文件夹"
|
||||
},
|
||||
"filterCategories": {
|
||||
"genre": "类型",
|
||||
"year": "年份",
|
||||
"contentRating": "内容分级",
|
||||
"tag": "标签"
|
||||
},
|
||||
"sortLabels": {
|
||||
"title": "标题",
|
||||
"dateAdded": "添加日期",
|
||||
"releaseDate": "发行日期",
|
||||
"rating": "评分",
|
||||
"lastPlayed": "最近播放",
|
||||
"playCount": "播放次数",
|
||||
"random": "随机",
|
||||
"dateShared": "共享日期",
|
||||
"latestEpisodeAirDate": "最新一集播出日期"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"openSourceLicenses": "开源许可证",
|
||||
"versionLabel": "版本 ${version}",
|
||||
"appDescription": "一款精美的 Flutter Plex 客户端",
|
||||
"appDescription": "一款精美的 Flutter Plex 和 Jellyfin 客户端",
|
||||
"viewLicensesDescription": "查看第三方库的许可证"
|
||||
},
|
||||
"serverSelection": {
|
||||
@@ -766,7 +850,9 @@
|
||||
"waitingForParticipants": "等待其他人加载...",
|
||||
"recentRooms": "最近的房间",
|
||||
"renameRoom": "重命名房间",
|
||||
"removeRoom": "移除"
|
||||
"removeRoom": "移除",
|
||||
"guestSwitchUnavailable": "无法切换 — 服务器无法同步",
|
||||
"guestSwitchFailed": "无法切换 — 在此服务器上未找到内容"
|
||||
},
|
||||
"downloads": {
|
||||
"title": "下载",
|
||||
@@ -817,6 +903,12 @@
|
||||
"editSyncFilter": "同步筛选",
|
||||
"syncAllItems": "同步所有项目",
|
||||
"syncUnwatchedItems": "同步未观看项目",
|
||||
"syncRuleServerContext": "服务器: ${server} • ${status}",
|
||||
"syncRuleAvailable": "可用",
|
||||
"syncRuleOffline": "离线",
|
||||
"syncRuleSignInRequired": "需要登录",
|
||||
"syncRuleNotAvailableForProfile": "当前个人资料不可用",
|
||||
"syncRuleUnknownServer": "未知服务器",
|
||||
"syncRuleListCreated": "同步规则已创建"
|
||||
},
|
||||
"shaders": {
|
||||
@@ -1077,5 +1169,39 @@
|
||||
"libraries": "媒体库",
|
||||
"noLibraries": "没有可用的媒体库"
|
||||
}
|
||||
},
|
||||
"addServer": {
|
||||
"addJellyfinTitle": "添加 Jellyfin 服务器",
|
||||
"jellyfinUrlIntro": "输入你的 Jellyfin 服务器 URL — 例如 `https://jellyfin.example.com`。可在之后登录。",
|
||||
"serverUrl": "服务器 URL",
|
||||
"findServer": "查找服务器",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"signIn": "登录",
|
||||
"change": "更改",
|
||||
"required": "必填",
|
||||
"couldNotReachServer": "无法连接到服务器: ${error}",
|
||||
"signInFailed": "登录失败: ${error}",
|
||||
"quickConnectFailed": "Quick Connect 失败: ${error}",
|
||||
"addPlexTitle": "使用 Plex 登录",
|
||||
"plexAuthIntro": "选择登录 Plex 的方式。浏览器流程会打开 plex.tv 让你确认连接;QR 选项适合电视 / 远程设备。",
|
||||
"plexQRPrompt": "扫描此 QR 码以登录。",
|
||||
"waitingForPlexConfirmation": "等待 plex.tv 确认登录…",
|
||||
"pinExpired": "PIN 在登录前已过期。请重试。",
|
||||
"duplicatePlexAccount": "此设备已登录到一个 Plex 帐户。请在设置中退出登录以切换帐户。",
|
||||
"failedToRegisterAccount": "注册帐户失败: ${error}",
|
||||
"enterJellyfinUrlError": "输入你的 Jellyfin 服务器 URL",
|
||||
"addConnectionTitle": "添加连接",
|
||||
"addConnectionTitleScoped": "添加到 ${name}",
|
||||
"addConnectionIntroGlobal": "添加另一台媒体服务器。你可以混合使用 Plex 帐户和 Jellyfin 服务器 — 所有已连接后端的项目会一起显示在主页。",
|
||||
"addConnectionIntroScoped": "添加新服务器,或从另一个配置文件借用。",
|
||||
"signInWithPlexCard": "使用 Plex 登录",
|
||||
"signInWithPlexCardSubtitle": "为你的 Plex 帐户授权此设备。与该帐户共享的服务器会自动加入。",
|
||||
"signInWithPlexCardSubtitleScoped": "授权一个新的 Plex 帐户。其 Home 用户会显示为配置文件。",
|
||||
"connectToJellyfinCard": "连接到 Jellyfin",
|
||||
"connectToJellyfinCardSubtitle": "输入你的 Jellyfin 服务器 URL,使用用户名 + 密码登录(Quick Connect 即将支持)。",
|
||||
"connectToJellyfinCardSubtitleScoped": "登录到 Jellyfin 服务器。绑定到 ${name}。",
|
||||
"borrowFromAnotherProfile": "从另一个配置文件借用",
|
||||
"borrowFromAnotherProfileSubtitle": "重用已附加到另一个配置文件的连接。受 PIN 保护的来源配置文件会要求输入 PIN。"
|
||||
}
|
||||
}
|
||||
|
||||
+273
-109
@@ -11,8 +11,19 @@ import 'package:window_manager/window_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'connection/connection.dart';
|
||||
import 'connection/connection_bootstrap.dart';
|
||||
import 'connection/connection_registry.dart';
|
||||
import 'profiles/active_profile_binder.dart';
|
||||
import 'profiles/active_profile_provider.dart';
|
||||
import 'profiles/profile.dart';
|
||||
import 'profiles/profile_connection_registry.dart';
|
||||
import 'profiles/profile_registry.dart';
|
||||
import 'profiles/plex_home_service.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/auth_screen.dart';
|
||||
import 'screens/profile/pin_entry_dialog.dart';
|
||||
import 'screens/profile/profile_switch_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/macos_window_service.dart';
|
||||
import 'services/native_window_service.dart';
|
||||
@@ -42,7 +53,6 @@ import 'utils/snackbar_helper.dart';
|
||||
import 'watch_together/providers/watch_together_provider.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
import 'services/server_connection_orchestrator.dart';
|
||||
import 'services/data_aggregation_service.dart';
|
||||
import 'services/in_app_review_service.dart';
|
||||
import 'services/server_registry.dart';
|
||||
@@ -50,13 +60,13 @@ import 'services/download_manager_service.dart';
|
||||
import 'services/pip_service.dart';
|
||||
import 'services/download_storage_service.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'services/jellyfin_api_cache.dart';
|
||||
import 'services/plex_api_cache.dart';
|
||||
import 'database/app_database.dart';
|
||||
import 'screens/video_player_screen.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
import 'utils/plex_http_client.dart' show httpClient;
|
||||
import 'utils/media_server_http_client.dart' show httpClient;
|
||||
import 'utils/orientation_helper.dart';
|
||||
import 'utils/global_key_utils.dart';
|
||||
import 'utils/watch_state_notifier.dart';
|
||||
import 'i18n/strings.g.dart';
|
||||
import 'focus/input_mode_tracker.dart';
|
||||
@@ -69,7 +79,6 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
||||
const String gitCommit = String.fromEnvironment('GIT_COMMIT');
|
||||
const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT');
|
||||
const String _plexTokenDefine = String.fromEnvironment('PLEX_TOKEN');
|
||||
|
||||
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
|
||||
// at (0,0) as barrier taps, causing modals to dismiss immediately.
|
||||
@@ -100,7 +109,10 @@ void _registerTvosPlatformPlugins() {
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||
// Build the semantics tree in debug so Maestro/UI automation can locate
|
||||
// widgets by text. Zero cost in release builds.
|
||||
if (kDebugMode) binding.ensureSemantics();
|
||||
_installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug
|
||||
|
||||
// On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS
|
||||
@@ -182,11 +194,9 @@ Future<void> _bootstrapApp() async {
|
||||
// Wait for all parallel services to complete
|
||||
await Future.wait(futures);
|
||||
|
||||
// Seed Plex token from dart-define (used by screenshot automation)
|
||||
if (_plexTokenDefine.isNotEmpty) {
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.savePlexToken(_plexTokenDefine);
|
||||
}
|
||||
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
||||
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
||||
// is available — keeps the deprecated legacy slots out of runtime paths.
|
||||
|
||||
// Initialize logger level based on debug setting
|
||||
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
||||
@@ -388,6 +398,17 @@ void _registerShaderLicenses() {
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the
|
||||
/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog
|
||||
/// renders correctly whether the binder fires from the splash, MainScreen,
|
||||
/// or any future host. Returns `null` when no Navigator is available yet
|
||||
/// (early boot, post-dispose) so the binder treats it as "PIN cancelled".
|
||||
Future<String?> _rootPinPrompt(Profile profile, {String? errorMessage}) {
|
||||
final ctx = rootNavigatorKey.currentContext;
|
||||
if (ctx == null) return Future.value(null);
|
||||
return showPinEntryDialog(ctx, profile.displayName, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
class MainApp extends StatefulWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@@ -439,9 +460,15 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
|
||||
// Initialize API cache with database
|
||||
PlexApiCache.initialize(_appDatabase);
|
||||
JellyfinApiCache.initialize(_appDatabase);
|
||||
|
||||
_downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance);
|
||||
_downloadManager.setClientResolver(_serverManager.getClient);
|
||||
_downloadManager.setClientResolver((serverId, {clientScopeId}) {
|
||||
if (clientScopeId != null && clientScopeId.isNotEmpty) {
|
||||
return _serverManager.getJellyfinClientByCompoundId(clientScopeId) ?? _serverManager.getClient(serverId);
|
||||
}
|
||||
return _serverManager.getClient(serverId);
|
||||
});
|
||||
_downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads();
|
||||
|
||||
_offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);
|
||||
@@ -536,10 +563,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
_isAutoDeleteRunning = true;
|
||||
try {
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
||||
final activeKey = VideoPlayerScreenState.activeId;
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) {
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey);
|
||||
if (deleted.isNotEmpty) {
|
||||
final msg = deleted.length == 1
|
||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||
@@ -616,7 +643,60 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (context) => MultiServerProvider(_serverManager, _aggregationService)),
|
||||
// Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup)
|
||||
// can manage stored Jellyfin/Plex connections without re-creating
|
||||
// the registry per-call site.
|
||||
Provider<AppDatabase>.value(value: _appDatabase),
|
||||
Provider<ConnectionRegistry>(create: (_) => ConnectionRegistry(_appDatabase)),
|
||||
Provider<ProfileRegistry>(create: (_) => ProfileRegistry(_appDatabase)),
|
||||
Provider<ProfileConnectionRegistry>(create: (_) => ProfileConnectionRegistry(_appDatabase)),
|
||||
Provider<PlexHomeService>(
|
||||
create: (context) {
|
||||
// start() resolves StorageService internally — the singleton was
|
||||
// already initialised eagerly during boot, so the await is a
|
||||
// microtask hop in practice.
|
||||
final service = PlexHomeService(
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
);
|
||||
unawaited(service.start());
|
||||
return service;
|
||||
},
|
||||
dispose: (_, s) => s.dispose(),
|
||||
),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>(
|
||||
create: (context) {
|
||||
final provider = ActiveProfileProvider(
|
||||
registry: context.read<ProfileRegistry>(),
|
||||
plexHome: context.read<PlexHomeService>(),
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
);
|
||||
unawaited(provider.initialize());
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) {
|
||||
_serverManager.onJellyfinConnectionUpdated = context.read<ConnectionRegistry>().upsert;
|
||||
return MultiServerProvider(_serverManager, _aggregationService);
|
||||
},
|
||||
),
|
||||
// Profile binder owns the cold-start client connect: Plex token
|
||||
// refresh + Jellyfin client creation. Hoisted out of MainScreen so
|
||||
// the splash can await its first settle — without this, MainScreen
|
||||
// mounts (and discover/libraries query) before any client exists.
|
||||
Provider<ActiveProfileBinder>(
|
||||
lazy: false,
|
||||
create: (context) => ActiveProfileBinder(
|
||||
activeProfile: context.read<ActiveProfileProvider>(),
|
||||
connections: context.read<ConnectionRegistry>(),
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
serverManager: _serverManager,
|
||||
multiServerProvider: context.read<MultiServerProvider>(),
|
||||
pinPrompt: _rootPinPrompt,
|
||||
)..start(),
|
||||
dispose: (_, binder) => binder.dispose(),
|
||||
),
|
||||
// Offline mode provider - depends on MultiServerProvider
|
||||
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
|
||||
create: (_) {
|
||||
@@ -630,15 +710,26 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
// Download provider
|
||||
ChangeNotifierProvider(
|
||||
// Download provider. Downloads are shared, but sync rules are scoped to
|
||||
// the active profile and reload when the profile changes.
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
||||
update: (context, activeProfile, previous) {
|
||||
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase);
|
||||
provider.setActiveProfileId(activeProfile.activeId);
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
// Offline watch sync service
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>(
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
|
||||
create: (context) {
|
||||
final offlineModeProvider = context.read<OfflineModeProvider>();
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
_offlineWatchSyncService.setActiveProfileId(
|
||||
activeProfile.activeId,
|
||||
availableProfileCount: activeProfile.profiles.length,
|
||||
);
|
||||
|
||||
// Offline-sync drain replays a batch of queued watch actions without
|
||||
// per-item data, so we can't target rules — force a full pass.
|
||||
@@ -652,12 +743,9 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// binge-watching coalesces into one pass.
|
||||
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
|
||||
if (event.changeType != WatchStateChangeType.watched) return;
|
||||
if (VideoPlayerScreenState.activeRatingKey == event.ratingKey) return;
|
||||
if (VideoPlayerScreenState.activeId == event.itemId) return;
|
||||
|
||||
_pendingSyncKeys.add(event.globalKey);
|
||||
for (final parentKey in event.parentChain) {
|
||||
_pendingSyncKeys.add(buildGlobalKey(event.serverId, parentKey));
|
||||
}
|
||||
_pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event));
|
||||
|
||||
_syncDebounce?.cancel();
|
||||
_syncDebounce = Timer(const Duration(seconds: 5), () {
|
||||
@@ -676,6 +764,11 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
_offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
|
||||
return _offlineWatchSyncService;
|
||||
},
|
||||
update: (_, activeProfile, previous) {
|
||||
final provider = previous ?? _offlineWatchSyncService;
|
||||
provider.setActiveProfileId(activeProfile.activeId, availableProfileCount: activeProfile.profiles.length);
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
// Offline watch provider - depends on sync service and download provider
|
||||
ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
|
||||
@@ -688,7 +781,19 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
// Existing providers
|
||||
ChangeNotifierProvider(create: (context) => UserProfileProvider()),
|
||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||
create: (_) => UserProfileProvider(),
|
||||
update: (context, activeProfile, connections, previous) {
|
||||
final provider = previous ?? UserProfileProvider();
|
||||
provider.attach(
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: context.read<ProfileConnectionRegistry>(),
|
||||
serverManager: context.read<MultiServerProvider>().serverManager,
|
||||
);
|
||||
return provider;
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
||||
// Tracker accounts — depend on UserProfileProvider for per-profile
|
||||
// session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`.
|
||||
@@ -809,15 +914,15 @@ class _AppleTvScale extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrates Trakt and MAL/AniList/Simkl providers with the active Plex
|
||||
/// profile's sessions and rebinds their services whenever the user switches.
|
||||
/// Hydrates Trakt and MAL/AniList/Simkl providers with the active profile's
|
||||
/// sessions and rebinds their services whenever the user switches profiles.
|
||||
///
|
||||
/// Lives high in the widget tree (above MaterialApp) so the listener survives
|
||||
/// route changes. [onFirstMount] runs exactly once after the first
|
||||
/// `didChangeDependencies`.
|
||||
class _TrackerProfileBootstrap extends StatefulWidget {
|
||||
final Widget child;
|
||||
final List<Future<void> Function(String? uuid)> onProfileChanged;
|
||||
final List<Future<void> Function(String? profileId)> onProfileChanged;
|
||||
final VoidCallback? onFirstMount;
|
||||
|
||||
const _TrackerProfileBootstrap({required this.child, required this.onProfileChanged, this.onFirstMount});
|
||||
@@ -827,19 +932,19 @@ class _TrackerProfileBootstrap extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> {
|
||||
UserProfileProvider? _profile;
|
||||
String? _lastUuid;
|
||||
ActiveProfileProvider? _provider;
|
||||
String? _lastId;
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final profile = context.read<UserProfileProvider>();
|
||||
final provider = context.read<ActiveProfileProvider>();
|
||||
|
||||
if (!identical(_profile, profile)) {
|
||||
_profile?.removeListener(_onProfileChanged);
|
||||
_profile = profile;
|
||||
_profile!.addListener(_onProfileChanged);
|
||||
if (!identical(_provider, provider)) {
|
||||
_provider?.removeListener(_onProfileChanged);
|
||||
_provider = provider;
|
||||
_provider!.addListener(_onProfileChanged);
|
||||
}
|
||||
|
||||
if (!_initialized) {
|
||||
@@ -850,12 +955,12 @@ class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> {
|
||||
}
|
||||
|
||||
void _onProfileChanged() {
|
||||
final uuid = _profile?.currentUser?.uuid;
|
||||
if (uuid == _lastUuid) return;
|
||||
_lastUuid = uuid;
|
||||
final id = _provider?.activeId;
|
||||
if (id == _lastId) return;
|
||||
_lastId = id;
|
||||
for (final fn in widget.onProfileChanged) {
|
||||
unawaited(
|
||||
fn(uuid).catchError((Object e, StackTrace s) {
|
||||
fn(id).catchError((Object e, StackTrace s) {
|
||||
appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s);
|
||||
}),
|
||||
);
|
||||
@@ -864,7 +969,7 @@ class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_profile?.removeListener(_onProfileChanged);
|
||||
_provider?.removeListener(_onProfileChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -925,6 +1030,25 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
final storage = await StorageService.getInstance();
|
||||
final registry = ServerRegistry(storage);
|
||||
|
||||
// Idempotent: brings legacy SharedPreferences state (plexToken,
|
||||
// currentUserUUID, homeUsersCache) into the new ConnectionRegistry +
|
||||
// ProfileRegistry tables. No-op on subsequent launches.
|
||||
if (mounted) {
|
||||
try {
|
||||
final connRegistry = context.read<ConnectionRegistry>();
|
||||
final profileRegistry = context.read<ProfileRegistry>();
|
||||
final bootstrap = ConnectionBootstrap(
|
||||
storage: storage,
|
||||
connectionRegistry: connRegistry,
|
||||
serverRegistry: registry,
|
||||
profileRegistry: profileRegistry,
|
||||
);
|
||||
await bootstrap.run();
|
||||
} catch (e, st) {
|
||||
appLogger.w('Boot-time migration failed', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
// Check network connectivity early to fast-path airplane mode.
|
||||
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
|
||||
bool hasNetwork;
|
||||
@@ -944,28 +1068,17 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')),
|
||||
);
|
||||
|
||||
if (hasNetwork) {
|
||||
_setStatus(t.common.refreshingServers);
|
||||
|
||||
// Refresh servers from API to get updated connection info (IPs may change).
|
||||
// If the stored token is invalid (e.g. after removing a Plex profile PIN),
|
||||
// redirect to AuthScreen so the user can re-authenticate.
|
||||
final refreshResult = await registry.refreshServersFromApi();
|
||||
if (refreshResult == ServerRefreshResult.authError) {
|
||||
await storage.clearCredentials();
|
||||
if (mounted) {
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(t.common.loadingServers);
|
||||
|
||||
// Load all configured servers
|
||||
final servers = await registry.getServers();
|
||||
if (!mounted) return;
|
||||
|
||||
if (servers.isEmpty) {
|
||||
// Snapshot ConnectionRegistry before we cross any awaits — Provider lookups
|
||||
// through `context` after async gaps trip the use_build_context_synchronously
|
||||
// lint, and reading early is safe because the registry is a singleton.
|
||||
final connectionRegistry = context.read<ConnectionRegistry>();
|
||||
final allConnections = await connectionRegistry.list();
|
||||
|
||||
if (allConnections.isEmpty) {
|
||||
if (mounted) {
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
||||
}
|
||||
@@ -983,67 +1096,118 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'setup')),
|
||||
);
|
||||
_setStatus(t.common.connectingToServers);
|
||||
|
||||
// Populate per-server status for splash display
|
||||
// Populate per-server status from the registry for the splash list.
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
for (final server in servers) {
|
||||
_serverStatus[server.clientIdentifier] = (server.name, null);
|
||||
for (final conn in allConnections) {
|
||||
if (conn is PlexAccountConnection) {
|
||||
for (final s in conn.servers) {
|
||||
_serverStatus[s.clientIdentifier] = (s.name, null);
|
||||
}
|
||||
} else if (conn is JellyfinConnection) {
|
||||
_serverStatus[conn.serverMachineId] = (conn.serverName, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await ServerConnectionOrchestrator.connectAndInitialize(
|
||||
servers: servers,
|
||||
multiServerProvider: context.read<MultiServerProvider>(),
|
||||
librariesProvider: context.read<LibrariesProvider>(),
|
||||
syncService: context.read<OfflineWatchSyncService>(),
|
||||
clientIdentifier: storage.getClientIdentifier(),
|
||||
onServerStatus: (serverId, success) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
final existing = _serverStatus[serverId];
|
||||
if (existing != null) {
|
||||
_serverStatus[serverId] = (existing.$1, success);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
final plexCount = allConnections.whereType<PlexAccountConnection>().fold<int>(0, (n, c) => n + c.servers.length);
|
||||
final jellyfinCount = allConnections.whereType<JellyfinConnection>().length;
|
||||
unawaited(
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(
|
||||
message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin',
|
||||
category: 'setup',
|
||||
),
|
||||
),
|
||||
);
|
||||
_setStatus(t.common.connectingToServers);
|
||||
|
||||
// Snapshot Provider refs before further awaits.
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
// Reading the binder here is enough — the Provider is `lazy: false` so
|
||||
// it has already constructed the binder and called `start()` during
|
||||
// MultiProvider build. We just need to wait for it.
|
||||
context.read<ActiveProfileBinder>();
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
|
||||
// Wait for the active profile to load from disk so the binder has a
|
||||
// profile to bind. `initialize` is fire-and-forget at provider creation,
|
||||
// so awaiting here pulls control through the same future and triggers
|
||||
// the listener-driven rebind synchronously.
|
||||
await activeProfile.initialize();
|
||||
if (!mounted) return;
|
||||
|
||||
// Wire the per-server status listener before either branch so the splash
|
||||
// checkmarks fill in even while the user is choosing a profile.
|
||||
_bindServerStatusListener(activeProfile, _serverManagerFromContext);
|
||||
|
||||
// If "prompt for profile on launch" is on (or no profile is selected
|
||||
// yet), surface the picker BEFORE waiting for the previously-active
|
||||
// profile's bind to settle — otherwise the user sees the splash fully
|
||||
// connect before the prompt arrives. The picker's own `_switchTo` calls
|
||||
// `awaitBindingSettle` after activation, so by the time it pops, the
|
||||
// chosen profile's bind is settled.
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||
final requireOnOpen =
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
||||
final shouldPrompt = hasNoActive || requireOnOpen;
|
||||
|
||||
if (shouldPrompt) {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
|
||||
if (!mounted) return;
|
||||
} else {
|
||||
// Now wait for the binder to settle. This is the Plex/Jellyfin server
|
||||
// race: per-server status flips on the splash list as each client comes
|
||||
// online, and we don't push MainScreen until they're all done (success
|
||||
// or fail). Eliminates the "Failed to load discover content: No servers
|
||||
// available" race the old eager-navigate flow caused.
|
||||
await activeProfile.awaitBindingSettle();
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.hasConnections && result.firstClient != null) {
|
||||
// Resume any downloads that were interrupted by app kill
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
unawaited(
|
||||
downloadProvider.ensureInitialized().then((_) {
|
||||
downloadProvider.resumeQueuedDownloads(result.firstClient!);
|
||||
}),
|
||||
);
|
||||
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!))));
|
||||
} else {
|
||||
_setStatus(t.common.startingOfflineMode);
|
||||
await context.read<DownloadProvider>().ensureInitialized();
|
||||
if (!mounted) return;
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace);
|
||||
|
||||
if (mounted) {
|
||||
_setStatus(t.common.startingOfflineMode);
|
||||
await context.read<DownloadProvider>().ensureInitialized();
|
||||
if (!mounted) return;
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||
}
|
||||
}
|
||||
|
||||
// Repopulate metadata for downloaded items now that per-backend caches
|
||||
// are resolvable (the Connections row + live JellyfinClient are in
|
||||
// place). Without this the downloads list and sync-rule titles render
|
||||
// empty until something forces a later refresh.
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
if (!mounted) return;
|
||||
|
||||
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: shouldPrompt))));
|
||||
}
|
||||
|
||||
/// Wire per-server status updates from [MultiServerManager] into the
|
||||
/// splash list so the user sees check/cross marks land as the binder
|
||||
/// brings each client online. Best-effort: stops listening when the
|
||||
/// state goes away.
|
||||
StreamSubscription<Map<String, bool>>? _statusSub;
|
||||
|
||||
void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) {
|
||||
_statusSub?.cancel();
|
||||
final manager = resolveManager();
|
||||
_statusSub = manager.statusStream.listen((status) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final entry in status.entries) {
|
||||
final existing = _serverStatus[entry.key];
|
||||
if (existing != null) {
|
||||
_serverStatus[entry.key] = (existing.$1, entry.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
MultiServerManager _serverManagerFromContext() => context.read<MultiServerProvider>().serverManager;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildStatusText(BuildContext context) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/// Backend-neutral download resolution types.
|
||||
///
|
||||
/// Returned by [MediaServerClient] download-resolution methods so the
|
||||
/// [DownloadManagerService] doesn't need to know whether it's talking to
|
||||
/// Plex or Jellyfin.
|
||||
library;
|
||||
|
||||
/// Spec for a single external subtitle track that should be downloaded
|
||||
/// alongside the video file.
|
||||
///
|
||||
/// `id` is a backend-stable integer used in the on-disk filename
|
||||
/// (Plex stream id, Jellyfin stream index).
|
||||
class DownloadSubtitleSpec {
|
||||
final int id;
|
||||
final String url;
|
||||
final String? codec;
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final bool forced;
|
||||
final String? displayTitle;
|
||||
|
||||
const DownloadSubtitleSpec({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.codec,
|
||||
this.language,
|
||||
this.languageCode,
|
||||
this.forced = false,
|
||||
this.displayTitle,
|
||||
});
|
||||
}
|
||||
|
||||
/// Spec for a single artwork file. `localKey` is the deterministic key the
|
||||
/// storage service hashes to compute the on-disk filename — Plex passes the
|
||||
/// backend-relative path so cache deduplication works across items that
|
||||
/// reference the same blob; Jellyfin passes the absolute URL since artwork
|
||||
/// URLs are already unique per item after stripping auth query parameters.
|
||||
class DownloadArtworkSpec {
|
||||
final String localKey;
|
||||
final String url;
|
||||
|
||||
const DownloadArtworkSpec({required this.localKey, required this.url});
|
||||
}
|
||||
|
||||
/// Bundle of everything the download pipeline needs to fetch the primary
|
||||
/// video file and its companion subtitle sidecars for a chosen media
|
||||
/// version.
|
||||
class DownloadResolution {
|
||||
final String? videoUrl;
|
||||
final List<DownloadSubtitleSpec> externalSubtitles;
|
||||
|
||||
const DownloadResolution({required this.videoUrl, this.externalSubtitles = const []});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'media_filter.dart';
|
||||
|
||||
/// Combined filter listing result. Plex returns categories with no values
|
||||
/// pre-loaded; Jellyfin pre-populates [cachedValues] so the FiltersBottomSheet
|
||||
/// avoids a second round-trip per category.
|
||||
class LibraryFilterResult {
|
||||
final List<MediaFilter> filters;
|
||||
final Map<String, List<MediaFilterValue>> cachedValues;
|
||||
|
||||
const LibraryFilterResult({required this.filters, required this.cachedValues});
|
||||
|
||||
static const empty = LibraryFilterResult(filters: [], cachedValues: {});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// One entry in the alpha-jump bar — a letter (or `#` bucket for items
|
||||
/// starting with a digit / non-letter) plus the count of items beginning
|
||||
/// with it.
|
||||
///
|
||||
/// Plex's `/library/sections/{id}/firstCharacter` endpoint returns these
|
||||
/// natively (counts let the UI scroll to a cumulative offset). Jellyfin
|
||||
/// has no equivalent endpoint, so [JellyfinClient.fetchFirstCharacters]
|
||||
/// synthesises a 27-letter alphabet with `size: 1` per entry — the bar
|
||||
/// then acts as a name-prefix filter rather than a scroll affordance.
|
||||
class LibraryFirstCharacter {
|
||||
final String key;
|
||||
final String title;
|
||||
final int size;
|
||||
|
||||
const LibraryFirstCharacter({required this.key, required this.title, required this.size});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'media_kind.dart';
|
||||
|
||||
/// Sort order applied to a library query.
|
||||
enum LibrarySortDirection { ascending, descending }
|
||||
|
||||
class LibrarySort {
|
||||
/// Backend-neutral sort field. Common values: `addedAt`, `originallyAvailableAt`,
|
||||
/// `lastViewedAt`, `title`, `rating`, `viewCount`, `random`.
|
||||
final String field;
|
||||
final LibrarySortDirection direction;
|
||||
|
||||
const LibrarySort({required this.field, this.direction = LibrarySortDirection.descending});
|
||||
}
|
||||
|
||||
/// A single filter clause. The semantics of `field` and `value` are
|
||||
/// backend-translated — the neutral query just carries the intent.
|
||||
class LibraryFilter {
|
||||
final String field;
|
||||
final String op; // "=", "!=", "contains", ">=", etc.
|
||||
final List<String> values;
|
||||
|
||||
const LibraryFilter({required this.field, this.op = '=', required this.values});
|
||||
}
|
||||
|
||||
/// Backend-neutral library content query. Each backend's adapter translates
|
||||
/// these into its own query DSL (Plex `/library/sections/{id}/all?type=...`
|
||||
/// or Jellyfin `/Items?ParentId=...&Filters=...`).
|
||||
class LibraryQuery {
|
||||
/// Restrict to a single kind (e.g. `MediaKind.movie`). Null = library default.
|
||||
final MediaKind? kind;
|
||||
|
||||
/// Pagination — zero-based offset.
|
||||
final int offset;
|
||||
final int limit;
|
||||
|
||||
final LibrarySort? sort;
|
||||
final List<LibraryFilter> filters;
|
||||
|
||||
/// Free-text search restricted to this library. Distinct from the global
|
||||
/// search endpoint.
|
||||
final String? search;
|
||||
|
||||
/// Whether to include items the active user has already watched.
|
||||
final bool includeWatched;
|
||||
|
||||
/// Restrict the result to items whose sort name starts with this string —
|
||||
/// the alpha-jump bar's filter UX. The literal `#` is a sentinel for
|
||||
/// "non-alphabetic" and translates to a `NameLessThan=A` query for backends
|
||||
/// that support it.
|
||||
final String? nameStartsWith;
|
||||
|
||||
/// Genre filter — used by the per-library filter sheet. Backends that
|
||||
/// take multiple values (Jellyfin) AND/intersect; those that take one
|
||||
/// (Plex's existing flow) consult `filters` instead.
|
||||
final List<String>? genres;
|
||||
final List<String>? officialRatings;
|
||||
final List<int>? years;
|
||||
final List<String>? tags;
|
||||
|
||||
const LibraryQuery({
|
||||
this.kind,
|
||||
this.offset = 0,
|
||||
this.limit = 50,
|
||||
this.sort,
|
||||
this.filters = const [],
|
||||
this.search,
|
||||
this.includeWatched = true,
|
||||
this.nameStartsWith,
|
||||
this.genres,
|
||||
this.officialRatings,
|
||||
this.years,
|
||||
this.tags,
|
||||
});
|
||||
|
||||
LibraryQuery copyWith({
|
||||
MediaKind? kind,
|
||||
int? offset,
|
||||
int? limit,
|
||||
LibrarySort? sort,
|
||||
List<LibraryFilter>? filters,
|
||||
String? search,
|
||||
bool? includeWatched,
|
||||
String? nameStartsWith,
|
||||
List<String>? genres,
|
||||
List<String>? officialRatings,
|
||||
List<int>? years,
|
||||
List<String>? tags,
|
||||
}) {
|
||||
return LibraryQuery(
|
||||
kind: kind ?? this.kind,
|
||||
offset: offset ?? this.offset,
|
||||
limit: limit ?? this.limit,
|
||||
sort: sort ?? this.sort,
|
||||
filters: filters ?? this.filters,
|
||||
search: search ?? this.search,
|
||||
includeWatched: includeWatched ?? this.includeWatched,
|
||||
nameStartsWith: nameStartsWith ?? this.nameStartsWith,
|
||||
genres: genres ?? this.genres,
|
||||
officialRatings: officialRatings ?? this.officialRatings,
|
||||
years: years ?? this.years,
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Page of items returned by [MediaServerClient.getLibraryContent].
|
||||
/// Carries the total count so the UI can render correct pagination affordances.
|
||||
class LibraryPage<T> {
|
||||
final List<T> items;
|
||||
final int totalCount;
|
||||
final int offset;
|
||||
|
||||
const LibraryPage({required this.items, required this.totalCount, this.offset = 0});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_program.dart';
|
||||
|
||||
enum FavoriteChannelPersistenceMode {
|
||||
/// A single write replaces the full backend account's favorite list.
|
||||
sharedFullList,
|
||||
|
||||
/// Writes must only include the favorites owned by this server/source.
|
||||
serverSlice,
|
||||
}
|
||||
|
||||
class LiveTvStreamResolution {
|
||||
final String url;
|
||||
final String? playSessionId;
|
||||
|
||||
const LiveTvStreamResolution({required this.url, this.playSessionId});
|
||||
}
|
||||
|
||||
/// Backend-neutral live-TV operations. Implementations are obtained via
|
||||
/// [MediaServerClient.liveTv]; the getter returns `null` when the server has no
|
||||
/// live-TV support configured.
|
||||
///
|
||||
/// Plex servers expose multiple per-DVR lineups (`/livetv/dvrs`), Jellyfin
|
||||
/// servers expose a single flat channel list. The interface flattens both:
|
||||
/// callers that need DVR identity for Plex's per-lineup channel fetch use
|
||||
/// [fetchDvrs]; callers that only need the channel list pass the optional
|
||||
/// [lineup] (Plex provider identifier) to [fetchChannels].
|
||||
///
|
||||
/// Stream URL resolution differs sharply by backend: Plex's DVR allocates a
|
||||
/// transcode session and returns a session-scoped path that requires
|
||||
/// follow-up calls (`tuneChannel` + `buildLiveStreamPath`). Jellyfin returns a
|
||||
/// direct-play URL. [resolveStreamUrl] returns the Jellyfin URL directly;
|
||||
/// Plex callers use the existing `client + dvrKey` plumbing inside the player.
|
||||
abstract class LiveTvSupport {
|
||||
/// Fast probe — `true` when this server has live-TV configured. Plex calls
|
||||
/// `/livetv/dvrs` and returns true when any DVR exists; Jellyfin probes
|
||||
/// `/LiveTv/Channels?limit=1`.
|
||||
Future<bool> isAvailable();
|
||||
|
||||
/// Plex returns one entry per configured DVR; Jellyfin returns an empty
|
||||
/// list (it has no per-DVR partitioning).
|
||||
Future<List<LiveTvDvr>> fetchDvrs();
|
||||
|
||||
/// Channel list. Plex callers may pass [lineup] (the EPG provider
|
||||
/// identifier from a DVR's lineup) to scope to a specific provider's
|
||||
/// channels. Jellyfin ignores [lineup] and returns the flat list.
|
||||
Future<List<LiveTvChannel>> fetchChannels({String? lineup});
|
||||
|
||||
/// EPG / programs grid covering [from]..[to]. Plex queries
|
||||
/// `/livetv/dvrs/{dvrKey}/grid`; Jellyfin queries `/LiveTv/Programs`.
|
||||
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to});
|
||||
|
||||
/// Resolve a playable stream URL for [channelKey].
|
||||
///
|
||||
/// Jellyfin returns a negotiated stream URL plus the play session id. Plex
|
||||
/// returns `null` because its stream URL is only valid after a `tuneChannel`
|
||||
/// call; the player's Plex branch uses `client + dvrKey` instead.
|
||||
Future<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey});
|
||||
|
||||
/// Source URI to stamp into [FavoriteChannel] entries. Plex uses
|
||||
/// `server://{machineId}/{providerId}` so its cloud-synced favorites are
|
||||
/// keyed per EPG provider. Jellyfin uses `server://{serverId}/jellyfin`
|
||||
/// (no provider concept).
|
||||
Future<String> buildFavoriteChannelSource({String? lineup});
|
||||
|
||||
/// Runtime store identity used to avoid fetching/writing a shared favorite
|
||||
/// backend more than once. Plex is cloud/account-scoped; Jellyfin is
|
||||
/// server-user scoped.
|
||||
String get favoriteStoreKey;
|
||||
|
||||
FavoriteChannelPersistenceMode get favoritePersistenceMode;
|
||||
|
||||
/// Read the user's favorite channels for this server. Plex pulls from the
|
||||
/// cloud-synced list; Jellyfin queries `IsFavorite=true` with locally
|
||||
/// stored ordering.
|
||||
Future<List<FavoriteChannel>> fetchFavoriteChannels();
|
||||
|
||||
/// Persist the favorites list (and order, where supported). Plex pushes
|
||||
/// to its cloud sync endpoint; Jellyfin POSTs/DELETEs the
|
||||
/// `/Users/{userId}/FavoriteItems/{channelId}` flag and saves the order
|
||||
/// locally.
|
||||
Future<void> setFavoriteChannels(List<FavoriteChannel> channels);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Backend identifier for a media item, library, or server.
|
||||
///
|
||||
/// Used as a discriminator on neutral domain types so consumers can branch on
|
||||
/// backend-specific behavior (e.g. only Plex supports server-side play queues
|
||||
/// in v1) and so persisted records can round-trip the source of an item.
|
||||
enum MediaBackend {
|
||||
plex,
|
||||
jellyfin;
|
||||
|
||||
String get id => switch (this) {
|
||||
MediaBackend.plex => 'plex',
|
||||
MediaBackend.jellyfin => 'jellyfin',
|
||||
};
|
||||
|
||||
static MediaBackend fromId(String id) => switch (id) {
|
||||
'plex' => MediaBackend.plex,
|
||||
'jellyfin' => MediaBackend.jellyfin,
|
||||
_ => throw ArgumentError('Unknown MediaBackend id: $id'),
|
||||
};
|
||||
|
||||
/// Like [fromId] but tolerates legacy/missing values by defaulting to Plex.
|
||||
/// Used by JSON deserialization of cached offline data:
|
||||
/// - `null` is the pre-Jellyfin shape and silently defaults to Plex.
|
||||
/// - An unrecognized non-null id logs a warning and defaults to Plex; this
|
||||
/// surfaces corrupted cache rows or schema drift instead of silently
|
||||
/// misclassifying Jellyfin items as Plex.
|
||||
static MediaBackend fromString(String? id) {
|
||||
if (id != null && id != 'plex' && id != 'jellyfin') {
|
||||
appLogger.w('Unknown MediaBackend id "$id"; defaulting to plex');
|
||||
}
|
||||
return switch (id) {
|
||||
'jellyfin' => MediaBackend.jellyfin,
|
||||
_ => MediaBackend.plex,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import '../utils/formatters.dart';
|
||||
import 'plex_media_info.dart';
|
||||
import 'media_source_info.dart';
|
||||
|
||||
class PlexFileInfo {
|
||||
/// Backend-neutral file-info payload rendered by [FileInfoBottomSheet].
|
||||
///
|
||||
/// Both Plex and Jellyfin can populate any subset of these fields; rows that
|
||||
/// don't apply to the active backend are left null and skipped at render
|
||||
/// time. Plex fills the full set from `/library/metadata/{id}` (Media + Part
|
||||
/// + Stream); Jellyfin fills the subset that's available on inline
|
||||
/// `MediaSources`.
|
||||
class MediaFileInfo {
|
||||
// Media level properties
|
||||
final String? container;
|
||||
final String? videoCodec;
|
||||
@@ -34,10 +41,10 @@ class PlexFileInfo {
|
||||
final String? audioChannelLayout;
|
||||
|
||||
// Multi-track support
|
||||
final List<PlexAudioTrack> audioTracks;
|
||||
final List<PlexSubtitleTrack> subtitleTracks;
|
||||
final List<MediaAudioTrack> audioTracks;
|
||||
final List<MediaSubtitleTrack> subtitleTracks;
|
||||
|
||||
PlexFileInfo({
|
||||
MediaFileInfo({
|
||||
this.container,
|
||||
this.videoCodec,
|
||||
this.videoResolution,
|
||||
@@ -67,73 +74,48 @@ class PlexFileInfo {
|
||||
this.subtitleTracks = const [],
|
||||
});
|
||||
|
||||
/// Format file size in human-readable format (GB, MB, KB, bytes)
|
||||
String get fileSizeFormatted {
|
||||
if (fileSize == null) return 'Unknown';
|
||||
String? get fileSizeFormatted {
|
||||
if (fileSize == null) return null;
|
||||
return ByteFormatter.formatBytes(fileSize!, decimals: 2);
|
||||
}
|
||||
|
||||
/// Format duration in HH:MM:SS or MM:SS format
|
||||
String get durationFormatted {
|
||||
if (duration == null) return 'Unknown';
|
||||
|
||||
String? get durationFormatted {
|
||||
if (duration == null) return null;
|
||||
final seconds = duration! ~/ 1000;
|
||||
final hours = seconds ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
final secs = seconds % 60;
|
||||
|
||||
return hours > 0 ? '${hours}h ${minutes}m ${secs}s' : '${minutes}m ${secs}s';
|
||||
}
|
||||
|
||||
/// Format overall bitrate in Mbps or kbps (Plex API returns kbps)
|
||||
String get bitrateFormatted {
|
||||
if (bitrate == null) return 'Unknown';
|
||||
String? get bitrateFormatted {
|
||||
if (bitrate == null) return null;
|
||||
return ByteFormatter.formatBitrate(bitrate!);
|
||||
}
|
||||
|
||||
/// Format video stream bitrate in Mbps or kbps
|
||||
String get videoBitrateFormatted {
|
||||
if (videoBitrate == null) return 'Unknown';
|
||||
String? get videoBitrateFormatted {
|
||||
if (videoBitrate == null) return null;
|
||||
return ByteFormatter.formatBitrate(videoBitrate!);
|
||||
}
|
||||
|
||||
/// Format resolution as widthxheight
|
||||
String get resolutionFormatted {
|
||||
if (width != null && height != null) {
|
||||
return '${width}x$height';
|
||||
} else if (videoResolution != null) {
|
||||
return videoResolution!;
|
||||
}
|
||||
return 'Unknown';
|
||||
String? get resolutionFormatted {
|
||||
if (width != null && height != null) return '${width}x$height';
|
||||
if (videoResolution != null) return videoResolution;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Format aspect ratio
|
||||
String get aspectRatioFormatted {
|
||||
if (aspectRatio != null) {
|
||||
return aspectRatio!.toStringAsFixed(2);
|
||||
}
|
||||
return 'Unknown';
|
||||
String? get aspectRatioFormatted => aspectRatio?.toStringAsFixed(2);
|
||||
|
||||
String? get frameRateFormatted {
|
||||
if (frameRate != null) return '${frameRate!.toStringAsFixed(3)} fps';
|
||||
if (videoFrameRate != null) return videoFrameRate;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Format frame rate
|
||||
String get frameRateFormatted {
|
||||
if (frameRate != null) {
|
||||
return '${frameRate!.toStringAsFixed(3)} fps';
|
||||
} else if (videoFrameRate != null) {
|
||||
return videoFrameRate!;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Format audio channels (e.g., "2 channels (stereo)")
|
||||
String get audioChannelsFormatted {
|
||||
if (audioChannels != null) {
|
||||
String channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
|
||||
if (audioChannelLayout != null) {
|
||||
channelText += ' ($audioChannelLayout)';
|
||||
}
|
||||
return channelText;
|
||||
}
|
||||
return 'Unknown';
|
||||
String? get audioChannelsFormatted {
|
||||
if (audioChannels == null) return null;
|
||||
var channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}';
|
||||
if (audioChannelLayout != null) channelText += ' ($audioChannelLayout)';
|
||||
return channelText;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_filter.g.dart';
|
||||
part 'media_filter.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexFilter {
|
||||
class MediaFilter {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String filter;
|
||||
@JsonKey(defaultValue: 'string')
|
||||
@@ -15,7 +15,7 @@ class PlexFilter {
|
||||
@JsonKey(defaultValue: 'filter')
|
||||
final String type;
|
||||
|
||||
PlexFilter({
|
||||
MediaFilter({
|
||||
required this.filter,
|
||||
required this.filterType,
|
||||
required this.key,
|
||||
@@ -23,22 +23,22 @@ class PlexFilter {
|
||||
required this.type,
|
||||
});
|
||||
|
||||
factory PlexFilter.fromJson(Map<String, dynamic> json) => _$PlexFilterFromJson(json);
|
||||
factory MediaFilter.fromJson(Map<String, dynamic> json) => _$MediaFilterFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexFilterToJson(this);
|
||||
Map<String, dynamic> toJson() => _$MediaFilterToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class PlexFilterValue {
|
||||
class MediaFilterValue {
|
||||
@JsonKey(defaultValue: '')
|
||||
final String key;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String title;
|
||||
final String? type;
|
||||
|
||||
PlexFilterValue({required this.key, required this.title, this.type});
|
||||
MediaFilterValue({required this.key, required this.title, this.type});
|
||||
|
||||
factory PlexFilterValue.fromJson(Map<String, dynamic> json) => _$PlexFilterValueFromJson(json);
|
||||
factory MediaFilterValue.fromJson(Map<String, dynamic> json) => _$MediaFilterValueFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexFilterValueToJson(this);
|
||||
Map<String, dynamic> toJson() => _$MediaFilterValueToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'media_filter.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
MediaFilter _$MediaFilterFromJson(Map<String, dynamic> json) => MediaFilter(
|
||||
filter: json['filter'] as String? ?? '',
|
||||
filterType: json['filterType'] as String? ?? 'string',
|
||||
key: json['key'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'filter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MediaFilterToJson(MediaFilter instance) => <String, dynamic>{
|
||||
'filter': instance.filter,
|
||||
'filterType': instance.filterType,
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
};
|
||||
|
||||
MediaFilterValue _$MediaFilterValueFromJson(Map<String, dynamic> json) => MediaFilterValue(
|
||||
key: json['key'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$MediaFilterValueToJson(MediaFilterValue instance) => <String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': ?instance.type,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
/// A named, ordered list of items grouped on the home screen (Plex `Hub`,
|
||||
/// or a synthesized Jellyfin "Latest"/"Resume"/"NextUp" row).
|
||||
class MediaHub {
|
||||
/// Backend-opaque hub identifier (Plex `key`, synthesized for Jellyfin).
|
||||
final String id;
|
||||
|
||||
/// Human-readable hub identifier for analytics and routing — e.g.
|
||||
/// `home.continue`, `tv.recentlyadded`. Synthesized for Jellyfin.
|
||||
final String? identifier;
|
||||
|
||||
final String title;
|
||||
|
||||
/// Hub kind: `movie`, `show`, `mixed`, `clip`, etc. — drives UI rendering.
|
||||
final String type;
|
||||
|
||||
final List<MediaItem> items;
|
||||
|
||||
/// Total number of items the server reports (may exceed [items.length] when
|
||||
/// a "see more" affordance is available).
|
||||
final int size;
|
||||
|
||||
/// Whether more items are available beyond what's loaded.
|
||||
final bool more;
|
||||
|
||||
/// When set, this hub was split from a multi-library hub and should only
|
||||
/// show items belonging to this library.
|
||||
final String? libraryId;
|
||||
|
||||
final String? serverId;
|
||||
final String? serverName;
|
||||
|
||||
const MediaHub({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.type,
|
||||
required this.items,
|
||||
this.identifier,
|
||||
this.size = 0,
|
||||
this.more = false,
|
||||
this.libraryId,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
MediaHub copyWith({
|
||||
String? id,
|
||||
String? identifier,
|
||||
String? title,
|
||||
String? type,
|
||||
List<MediaItem>? items,
|
||||
int? size,
|
||||
bool? more,
|
||||
String? libraryId,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
return MediaHub(
|
||||
id: id ?? this.id,
|
||||
identifier: identifier ?? this.identifier,
|
||||
title: title ?? this.title,
|
||||
type: type ?? this.type,
|
||||
items: items ?? this.items,
|
||||
size: size ?? this.size,
|
||||
more: more ?? this.more,
|
||||
libraryId: libraryId ?? this.libraryId,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import 'media_item.dart';
|
||||
import 'media_kind.dart';
|
||||
|
||||
/// Convenience type-check getters and spoiler helpers on [MediaItem]. These
|
||||
/// give consumers a Plex-style fluent API (e.g. `item.isShow`) while keeping
|
||||
/// the underlying type backend-neutral.
|
||||
extension MediaItemTypes on MediaItem {
|
||||
bool get isShow => kind == MediaKind.show;
|
||||
bool get isMovie => kind == MediaKind.movie;
|
||||
bool get isSeason => kind == MediaKind.season;
|
||||
bool get isEpisode => kind == MediaKind.episode;
|
||||
bool get isCollection => kind == MediaKind.collection;
|
||||
bool get isMusicContent => kind == MediaKind.artist || kind == MediaKind.album || kind == MediaKind.track;
|
||||
bool get isVideoContent =>
|
||||
kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season || kind == MediaKind.episode;
|
||||
|
||||
/// Whether this episode should have spoiler protection applied.
|
||||
/// True when the item is an unwatched episode watched less than 50%.
|
||||
bool get shouldHideSpoiler {
|
||||
if (!isEpisode) return false;
|
||||
if (isWatched) return false;
|
||||
if (viewOffsetMs != null && viewOffsetMs! > 0 && durationMs != null && durationMs! > 0) {
|
||||
return viewOffsetMs! / durationMs! < 0.5;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Non-spoiler art path for episodes (show/season background).
|
||||
String? get spoilerSafeArt => grandparentArtPath ?? artPath;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// Backend-neutral classification of a media item.
|
||||
///
|
||||
/// Mirrors the categories in [PlexMediaType] but is the canonical type used by
|
||||
/// neutral domain models. Each backend's adapter is responsible for mapping
|
||||
/// its own type strings (Plex `type` field, Jellyfin `BaseItemKind`) into one
|
||||
/// of these values.
|
||||
enum MediaKind {
|
||||
movie,
|
||||
show,
|
||||
season,
|
||||
episode,
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
collection,
|
||||
playlist,
|
||||
clip,
|
||||
photo,
|
||||
unknown;
|
||||
|
||||
bool get isVideo => this == movie || this == episode || this == clip;
|
||||
|
||||
bool get isShowRelated => this == show || this == season || this == episode;
|
||||
|
||||
bool get isMusic => this == artist || this == album || this == track;
|
||||
|
||||
bool get isPlayable => isVideo || this == track;
|
||||
|
||||
/// Lowercase string id used when persisting or comparing legacy code paths
|
||||
/// that still hold raw type strings.
|
||||
String get id => switch (this) {
|
||||
MediaKind.movie => 'movie',
|
||||
MediaKind.show => 'show',
|
||||
MediaKind.season => 'season',
|
||||
MediaKind.episode => 'episode',
|
||||
MediaKind.artist => 'artist',
|
||||
MediaKind.album => 'album',
|
||||
MediaKind.track => 'track',
|
||||
MediaKind.collection => 'collection',
|
||||
MediaKind.playlist => 'playlist',
|
||||
MediaKind.clip => 'clip',
|
||||
MediaKind.photo => 'photo',
|
||||
MediaKind.unknown => 'unknown',
|
||||
};
|
||||
|
||||
static MediaKind fromString(String? raw) {
|
||||
if (raw == null) return MediaKind.unknown;
|
||||
return switch (raw.toLowerCase()) {
|
||||
'movie' => MediaKind.movie,
|
||||
'show' || 'series' => MediaKind.show,
|
||||
'season' => MediaKind.season,
|
||||
'episode' => MediaKind.episode,
|
||||
'artist' || 'musicartist' => MediaKind.artist,
|
||||
'album' || 'musicalbum' => MediaKind.album,
|
||||
'track' || 'audio' => MediaKind.track,
|
||||
'collection' || 'boxset' => MediaKind.collection,
|
||||
'playlist' => MediaKind.playlist,
|
||||
'clip' || 'trailer' => MediaKind.clip,
|
||||
'photo' => MediaKind.photo,
|
||||
_ => MediaKind.unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import '../utils/global_key_utils.dart';
|
||||
import 'media_backend.dart';
|
||||
import 'media_kind.dart';
|
||||
|
||||
/// A top-level browseable section on a server (Plex library section, Jellyfin
|
||||
/// view).
|
||||
class MediaLibrary {
|
||||
/// Backend-opaque identifier (Plex section id like `"5"`, Jellyfin view UUID).
|
||||
final String id;
|
||||
final MediaBackend backend;
|
||||
final String title;
|
||||
|
||||
/// Primary media kind held by this library — drives default UI affordances
|
||||
/// (poster shape, sort options). For mixed libraries this is [MediaKind.unknown].
|
||||
final MediaKind kind;
|
||||
|
||||
/// Optional ISO language code of the library's metadata locale.
|
||||
final String? language;
|
||||
|
||||
/// Server-side last-update timestamp in seconds.
|
||||
final int? updatedAt;
|
||||
final int? createdAt;
|
||||
|
||||
/// Whether the user has hidden this library from the home browser.
|
||||
final bool hidden;
|
||||
|
||||
/// True for individually-shared items presented as a virtual library
|
||||
/// (Plex's "shared with me" surface). Jellyfin returns no such marker.
|
||||
final bool isShared;
|
||||
|
||||
final String? serverId;
|
||||
final String? serverName;
|
||||
|
||||
const MediaLibrary({
|
||||
required this.id,
|
||||
required this.backend,
|
||||
required this.title,
|
||||
this.kind = MediaKind.unknown,
|
||||
this.language,
|
||||
this.updatedAt,
|
||||
this.createdAt,
|
||||
this.hidden = false,
|
||||
this.isShared = false,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id;
|
||||
|
||||
MediaLibrary copyWith({
|
||||
String? id,
|
||||
MediaBackend? backend,
|
||||
String? title,
|
||||
MediaKind? kind,
|
||||
String? language,
|
||||
int? updatedAt,
|
||||
int? createdAt,
|
||||
bool? hidden,
|
||||
bool? isShared,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
return MediaLibrary(
|
||||
id: id ?? this.id,
|
||||
backend: backend ?? this.backend,
|
||||
title: title ?? this.title,
|
||||
kind: kind ?? this.kind,
|
||||
language: language ?? this.language,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
hidden: hidden ?? this.hidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'media_stream.dart';
|
||||
|
||||
/// One physical file part of a [MediaVersion]. A movie typically has a single
|
||||
/// part; some Plex multi-part files (CD1/CD2) and DVD/BluRay rips can have
|
||||
/// several. Jellyfin items always map to a single part per media source.
|
||||
class MediaPart {
|
||||
/// Backend-opaque part identifier.
|
||||
final String id;
|
||||
|
||||
/// Backend-specific path used to construct a direct stream URL — e.g. Plex's
|
||||
/// `/library/parts/123/file.mkv` or Jellyfin's `/Videos/{id}/stream`. The
|
||||
/// per-backend client is responsible for prefixing the base URL and
|
||||
/// appending auth.
|
||||
final String? streamPath;
|
||||
|
||||
final int? sizeBytes;
|
||||
final String? container;
|
||||
final int? durationMs;
|
||||
final bool? accessible;
|
||||
final bool? exists;
|
||||
final List<MediaStream> streams;
|
||||
|
||||
const MediaPart({
|
||||
required this.id,
|
||||
this.streamPath,
|
||||
this.sizeBytes,
|
||||
this.container,
|
||||
this.durationMs,
|
||||
this.accessible,
|
||||
this.exists,
|
||||
this.streams = const [],
|
||||
});
|
||||
|
||||
/// Defaults to true when fields are absent. Plex sets these only when the
|
||||
/// metadata request includes `checkFiles=1`.
|
||||
bool get isPlayable => accessible != false && exists != false;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import '../utils/global_key_utils.dart';
|
||||
import 'media_backend.dart';
|
||||
|
||||
/// Backend-neutral playlist record. Holds metadata only — items are fetched
|
||||
/// separately via the client.
|
||||
class MediaPlaylist {
|
||||
/// Backend-opaque identifier (Plex `ratingKey`, Jellyfin playlist `Id`).
|
||||
final String id;
|
||||
final MediaBackend backend;
|
||||
final String title;
|
||||
final String? summary;
|
||||
final String? guid;
|
||||
|
||||
/// Plex smart-playlist marker; always false for Jellyfin (no equivalent).
|
||||
final bool smart;
|
||||
|
||||
/// `video`, `audio`, or `photo`. Drives default sort and rendering.
|
||||
final String playlistType;
|
||||
|
||||
final int? durationMs;
|
||||
|
||||
/// Number of items in the playlist.
|
||||
final int? leafCount;
|
||||
final int? viewCount;
|
||||
|
||||
final int? addedAt;
|
||||
final int? updatedAt;
|
||||
final int? lastViewedAt;
|
||||
|
||||
/// Plex composite (auto-generated grid). Null on Jellyfin.
|
||||
final String? compositeImagePath;
|
||||
final String? thumbPath;
|
||||
|
||||
final String? serverId;
|
||||
final String? serverName;
|
||||
|
||||
const MediaPlaylist({
|
||||
required this.id,
|
||||
required this.backend,
|
||||
required this.title,
|
||||
required this.playlistType,
|
||||
this.summary,
|
||||
this.guid,
|
||||
this.smart = false,
|
||||
this.durationMs,
|
||||
this.leafCount,
|
||||
this.viewCount,
|
||||
this.addedAt,
|
||||
this.updatedAt,
|
||||
this.lastViewedAt,
|
||||
this.compositeImagePath,
|
||||
this.thumbPath,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
/// Image used to represent the playlist in browse views.
|
||||
String? get displayImagePath => compositeImagePath ?? thumbPath;
|
||||
|
||||
/// Display-friendly title (alias of [title] for parity with [MediaItem]).
|
||||
String get displayTitle => title;
|
||||
|
||||
/// Whether this playlist's contents can be reordered/edited by the client.
|
||||
/// Plex smart playlists are read-only; manual playlists and Jellyfin
|
||||
/// playlists are editable.
|
||||
bool get isEditable => !smart;
|
||||
|
||||
String get globalKey => serverId != null ? buildGlobalKey(serverId!, id) : id;
|
||||
|
||||
MediaPlaylist copyWith({
|
||||
String? id,
|
||||
MediaBackend? backend,
|
||||
String? title,
|
||||
String? summary,
|
||||
String? guid,
|
||||
bool? smart,
|
||||
String? playlistType,
|
||||
int? durationMs,
|
||||
int? leafCount,
|
||||
int? viewCount,
|
||||
int? addedAt,
|
||||
int? updatedAt,
|
||||
int? lastViewedAt,
|
||||
String? compositeImagePath,
|
||||
String? thumbPath,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
}) {
|
||||
return MediaPlaylist(
|
||||
id: id ?? this.id,
|
||||
backend: backend ?? this.backend,
|
||||
title: title ?? this.title,
|
||||
summary: summary ?? this.summary,
|
||||
guid: guid ?? this.guid,
|
||||
smart: smart ?? this.smart,
|
||||
playlistType: playlistType ?? this.playlistType,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
leafCount: leafCount ?? this.leafCount,
|
||||
viewCount: viewCount ?? this.viewCount,
|
||||
addedAt: addedAt ?? this.addedAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lastViewedAt: lastViewedAt ?? this.lastViewedAt,
|
||||
compositeImagePath: compositeImagePath ?? this.compositeImagePath,
|
||||
thumbPath: thumbPath ?? this.thumbPath,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// A cast or crew member attached to a media item.
|
||||
class MediaRole {
|
||||
final String? id;
|
||||
final String tag;
|
||||
final String? role;
|
||||
final String? thumbPath;
|
||||
|
||||
const MediaRole({this.id, required this.tag, this.role, this.thumbPath});
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import '../media/media_source_info.dart';
|
||||
import '../media/media_sort.dart';
|
||||
import '../services/api_cache.dart';
|
||||
import '../services/playback_initialization_types.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart' show AbortController, MediaServerResponse;
|
||||
import '../utils/external_ids.dart';
|
||||
import 'download_resolution.dart';
|
||||
import 'library_filter_result.dart';
|
||||
import 'library_first_character.dart';
|
||||
import 'library_query.dart';
|
||||
import 'live_tv_support.dart';
|
||||
import 'media_backend.dart';
|
||||
import 'media_file_info.dart';
|
||||
import 'media_hub.dart';
|
||||
import '../services/scrub_preview_source.dart';
|
||||
import 'media_item.dart';
|
||||
import 'media_kind.dart';
|
||||
import 'media_library.dart';
|
||||
import 'media_playlist.dart';
|
||||
import 'server_capabilities.dart';
|
||||
|
||||
/// Backend-neutral client for a single media server (Plex or Jellyfin).
|
||||
///
|
||||
/// Each implementation wraps the per-backend HTTP layer and exposes the same
|
||||
/// operations the rest of the app needs to browse libraries, mark watch
|
||||
/// state, and render items. Concrete classes ([PlexClient], `JellyfinClient`)
|
||||
/// own the per-backend networking — providers and UI consume them only
|
||||
/// through this interface.
|
||||
///
|
||||
/// ## Naming
|
||||
///
|
||||
/// Read methods use a `fetch*` prefix. Plex-only operations that have no
|
||||
/// Jellyfin equivalent (DVR tuning, metadata edit, match) live on
|
||||
/// [PlexClient] directly under their original `get*` / verb names.
|
||||
///
|
||||
/// ## Error contract (write methods)
|
||||
///
|
||||
/// All write methods (`markWatched`, `markUnwatched`, `removeFromContinueWatching`,
|
||||
/// `rate`, `createPlaylist`, `addToPlaylist`, `deletePlaylist`,
|
||||
/// `movePlaylistItem`, `removeFromPlaylist`, `createCollection`,
|
||||
/// `addToCollection`, `removeFromCollection`, `deleteCollection`,
|
||||
/// `deleteMediaItem`) follow the same contract:
|
||||
///
|
||||
/// - HTTP 4xx/5xx → throw [MediaServerHttpException].
|
||||
/// - Network/IO failure → throw the underlying exception.
|
||||
/// - Business "not applicable" (e.g. wrong-backend item handed to a
|
||||
/// write call) → return `false` without throwing.
|
||||
/// - Success → return the created entity / `true`.
|
||||
///
|
||||
/// `fetchItem` returns `null` on a real 404 (item gone) and on a 200 that
|
||||
/// can't be parsed; auth/server errors throw rather than silently dropping
|
||||
/// to `null`.
|
||||
///
|
||||
/// Callers that need to differentiate "operation impossible" from "server
|
||||
/// error" should `try`/`catch` the result and inspect the exception's
|
||||
/// `statusCode`.
|
||||
|
||||
/// Outcome of a health probe. Distinguishes "session expired" (token was
|
||||
/// rejected) from a generic transport failure, so the manager can route the
|
||||
/// two states to different UI ("Sign in again" vs "Server offline").
|
||||
enum HealthStatus { online, offline, authError }
|
||||
|
||||
abstract class MediaServerClient {
|
||||
// ── Identity ─────────────────────────────────────────────────────
|
||||
String get serverId;
|
||||
String? get serverName;
|
||||
MediaBackend get backend;
|
||||
ServerCapabilities get capabilities;
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
/// Release HTTP resources and any other long-lived state. Idempotent.
|
||||
void close();
|
||||
|
||||
/// Probe the server with a lightweight auth-required round-trip and
|
||||
/// classify the outcome. Implementations must surface 401/403 as
|
||||
/// [HealthStatus.authError] so the manager can flag a revoked token
|
||||
/// distinctly from a generic network failure.
|
||||
Future<HealthStatus> checkHealth();
|
||||
|
||||
/// Convenience predicate over [checkHealth] for callers that only need a
|
||||
/// boolean. Treats both `offline` and `authError` as unhealthy.
|
||||
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
|
||||
|
||||
/// Server-reported unique identifier (Plex `machineIdentifier`,
|
||||
/// Jellyfin `Id`). Returns `null` if the probe fails.
|
||||
Future<String?> getMachineIdentifier();
|
||||
|
||||
/// When `true`, the client serves cached responses only and never hits the
|
||||
/// network.
|
||||
bool get isOfflineMode;
|
||||
void setOfflineMode(bool offline);
|
||||
|
||||
/// Backend-specific cache substrate. Subclasses override this so the
|
||||
/// shared [MediaServerCacheMixin] helpers can read and write through the
|
||||
/// appropriate cache instance.
|
||||
ApiCache get cache;
|
||||
|
||||
// ── Browse: libraries ────────────────────────────────────────────
|
||||
Future<List<MediaLibrary>> fetchLibraries();
|
||||
|
||||
/// Page through items in [libraryId] using the neutral [query]. Backends
|
||||
/// translate sort/filter clauses into their own DSL.
|
||||
Future<LibraryPage<MediaItem>> fetchLibraryContent(String libraryId, LibraryQuery query);
|
||||
|
||||
/// Backend-aware paginated content fetch.
|
||||
///
|
||||
/// Pagination lives on [LibraryQuery.offset] / [LibraryQuery.limit].
|
||||
/// [libraryKind] disambiguates a Jellyfin "Shows" library so it returns
|
||||
/// Series rows rather than the recursive episode expansion the server
|
||||
/// defaults to. Plex ignores it (the section id already pins the type).
|
||||
///
|
||||
/// The previous `plexStyleFilters: Map<String,String>` parameter was
|
||||
/// retired — the library UI now builds a neutral [LibraryQuery] at the
|
||||
/// call boundary via `libraryQueryFromPlexMap`, and the Plex client
|
||||
/// translates back to wire params via [PlexLibraryQueryTranslator].
|
||||
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
|
||||
String libraryId, {
|
||||
required LibraryQuery query,
|
||||
MediaKind? libraryKind,
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Filter categories for [libraryId] plus any values the backend serves
|
||||
/// up-front. Plex returns categories without values (the FiltersBottomSheet
|
||||
/// fetches values lazily per category); Jellyfin returns both in a single
|
||||
/// `/Items/Filters` call and pre-populates [LibraryFilterResult.cachedValues].
|
||||
/// Backends that have no filter listing return [LibraryFilterResult.empty].
|
||||
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId);
|
||||
|
||||
/// Backend-aware sort options for [libraryId]. Plex hits
|
||||
/// `/library/sections/{id}/sorts`; Jellyfin returns a hardcoded list
|
||||
/// (the API has no equivalent endpoint). Returns [] when the server
|
||||
/// has no opinion. [libraryType] disambiguates Plex's per-type sort
|
||||
/// lists (movie vs show).
|
||||
Future<List<MediaSort>> fetchSortOptions(String libraryId, {String? libraryType});
|
||||
|
||||
/// First-character bucket counts for the alpha-jump bar in the library
|
||||
/// browse view. Plex returns real counts from
|
||||
/// `/library/sections/{id}/firstCharacter` (filterable); Jellyfin has no
|
||||
/// equivalent endpoint and synthesises a 27-letter alphabet so the bar
|
||||
/// can act as a name-prefix filter (`size: 1` per entry).
|
||||
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters});
|
||||
|
||||
/// Queue a metadata refresh for [libraryId]. The id is the backend-native
|
||||
/// library identifier (Plex section id / Jellyfin view item id, both
|
||||
/// surfaced via [MediaLibrary.key]). Plex hits
|
||||
/// `/library/sections/{id}/refresh?force=1`; Jellyfin posts to
|
||||
/// `/Items/{id}/Refresh` with `metadataRefreshMode=FullRefresh` (the
|
||||
/// library view is itself a Jellyfin item, and refresh recurses into its
|
||||
/// children).
|
||||
Future<void> refreshLibraryMetadata(String libraryId);
|
||||
|
||||
// ── Browse: items ────────────────────────────────────────────────
|
||||
/// Fetch a single item by its backend-opaque id. Returns `null` when the
|
||||
/// item no longer exists or the user can't see it.
|
||||
Future<MediaItem?> fetchItem(String id);
|
||||
|
||||
/// Fetch a single item *and* its on-deck episode (the next unwatched /
|
||||
/// in-progress episode) in one round-trip when the backend supports it.
|
||||
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
|
||||
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
|
||||
/// leaving callers to fetch on-deck separately if they need it.
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id);
|
||||
|
||||
/// Direct children of [parentId] — episodes of a season, seasons of a
|
||||
/// show, tracks of an album, items of a collection.
|
||||
Future<List<MediaItem>> fetchChildren(String parentId);
|
||||
|
||||
/// Playable descendants of [parentId] in one server-side query — for a
|
||||
/// show this returns every episode across every season; for a season the
|
||||
/// same episodes as [fetchChildren]; on Jellyfin a collection/playlist
|
||||
/// expands to its Movies + Episodes (Series containers are skipped).
|
||||
/// Used by playback launch (Jellyfin only — Plex routes containers
|
||||
/// through `/playQueues`) and by bulk download/sync (both backends) so
|
||||
/// neither has to walk show → seasons → episodes itself, and neither
|
||||
/// inherits a per-page Limit cap.
|
||||
///
|
||||
/// Plex hits `/library/metadata/{id}/grandchildren` (the only endpoint
|
||||
/// the server will one-shot for both show and season, and the
|
||||
/// recommended path for `skipChildren=true` mini-series); Jellyfin hits
|
||||
/// `/Items?ParentId={id}&Recursive=true&IncludeItemTypes=Movie,Episode`.
|
||||
/// Plex's choice means a *collection* ratingKey is not currently
|
||||
/// supported (no Plex consumer needs that today); add a kind-specific
|
||||
/// branch if/when one does.
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId);
|
||||
|
||||
/// All episodes of a series across every season, ordered by air date —
|
||||
/// used to build a centred 21-item navigation window when no server-side
|
||||
/// play queue is available. Returns `null` for backends that maintain
|
||||
/// queues server-side (Plex's `/playQueues`); returns the list (possibly
|
||||
/// empty for an empty series) for backends without that capability
|
||||
/// (Jellyfin). Callers distinguish "no client-side queue" from "empty
|
||||
/// series" via the null vs `[]` distinction.
|
||||
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId);
|
||||
|
||||
/// Free-text search across the user's libraries.
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 30});
|
||||
|
||||
/// Recently-added items across all libraries.
|
||||
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
|
||||
|
||||
/// Items the user has started but not finished. Plex calls this "On Deck"
|
||||
/// internally; the neutral name matches the Continue Watching UI surface.
|
||||
Future<List<MediaItem>> fetchContinueWatching({int count = 20});
|
||||
|
||||
// ── Browse: hubs ─────────────────────────────────────────────────
|
||||
/// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
|
||||
/// synthesizes `Latest` + `Resume` + `NextUp`).
|
||||
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10});
|
||||
|
||||
/// Hubs scoped to a single library section.
|
||||
Future<List<MediaHub>> fetchLibraryHubs(String libraryId, {int limit = 10});
|
||||
|
||||
/// "More like this" recommendations for [id].
|
||||
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});
|
||||
|
||||
/// Page through items in [hubId] when the hub previewed only the first N
|
||||
/// items (`MediaHub.more == true`). Plex hits `/hubs/{key}` (the same
|
||||
/// id used in [fetchGlobalHubs]); Jellyfin re-runs the synthesised query
|
||||
/// (Latest / Resume / NextUp) without the preview limit.
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit});
|
||||
|
||||
// ── Watch state ──────────────────────────────────────────────────
|
||||
/// Mark [item] as watched. The full item is passed (not just an id) so
|
||||
/// implementations can fire a [WatchStateEvent] on [WatchStateNotifier]
|
||||
/// for UI invalidation — episode/season/show parent chain, library
|
||||
/// section etc. live on the item.
|
||||
Future<void> markWatched(MediaItem item);
|
||||
Future<void> markUnwatched(MediaItem item);
|
||||
|
||||
/// Hide an item from Continue Watching without changing its watched
|
||||
/// status.
|
||||
Future<void> removeFromContinueWatching(MediaItem item);
|
||||
|
||||
/// Rate the item on a 0–10 scale. Backends without numeric ratings
|
||||
/// (Jellyfin) collapse to like/dislike — see [ServerCapabilities.numericUserRating].
|
||||
/// Throws [MediaServerHttpException] on failure, mirroring [markWatched] /
|
||||
/// [markUnwatched] / [removeFromContinueWatching] — callers wrap the
|
||||
/// awaited call in `try/catch` and surface a snackbar on the catch arm.
|
||||
Future<void> rate(MediaItem item, double rating);
|
||||
|
||||
// ── Playlists ────────────────────────────────────────────────────
|
||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart});
|
||||
|
||||
/// Metadata only — items are fetched via [fetchPlaylistItems].
|
||||
Future<MediaPlaylist?> fetchPlaylistMetadata(String id);
|
||||
|
||||
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100});
|
||||
|
||||
/// Create a new playlist seeded with [items]. Returns the created
|
||||
/// playlist on success, `null` on failure. Plex builds a metadata URI
|
||||
/// from the item ids; Jellyfin posts `Ids=<comma-joined>`.
|
||||
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items});
|
||||
|
||||
/// Append [items] to an existing playlist. Returns `true` on success.
|
||||
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items});
|
||||
|
||||
/// Delete [playlist] from the server. Returns `true` on success.
|
||||
Future<bool> deletePlaylist(MediaPlaylist playlist);
|
||||
|
||||
/// Move an item to a new position within a playlist. The item must have come
|
||||
/// from this client's [fetchPlaylistItems] (i.e. carry a per-playlist id).
|
||||
///
|
||||
/// [newIndex] - 0-based target position after the move
|
||||
/// [afterItem] - the item that should sit immediately before [item] after
|
||||
/// the move, or null when [newIndex] == 0. Plex uses this to derive its
|
||||
/// `?after=` query param; Jellyfin ignores it (it takes an absolute index).
|
||||
///
|
||||
/// Returns `false` (without throwing) if [item] is from the wrong backend
|
||||
/// or is missing its per-playlist id — callers should surface a snackbar.
|
||||
Future<bool> movePlaylistItem({
|
||||
required String playlistId,
|
||||
required MediaItem item,
|
||||
required int newIndex,
|
||||
required MediaItem? afterItem,
|
||||
});
|
||||
|
||||
/// Remove [item] from the playlist [playlistId]. See [movePlaylistItem] for
|
||||
/// the same caveats about backend tagging and the per-playlist id.
|
||||
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item});
|
||||
|
||||
// ── Collections ──────────────────────────────────────────────────
|
||||
/// Collections in [libraryId]. Plex hits `/library/sections/{id}/collections`;
|
||||
/// Jellyfin queries `/Items?ParentId={libraryId}&IncludeItemTypes=BoxSet`.
|
||||
/// Each result carries `kind == MediaKind.collection`.
|
||||
Future<List<MediaItem>> fetchCollections(String libraryId);
|
||||
|
||||
/// Page through items in [collectionId]. Plex paginates server-side via
|
||||
/// `/library/collections/{id}/children`; Jellyfin's API has no
|
||||
/// pagination knob for collection children, so its impl fetches the full
|
||||
/// list once (cached on the client) and slices locally. Callers can rely
|
||||
/// on [LibraryPage.totalCount] either way.
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Create a new collection in [libraryId] seeded with [items]. Returns the
|
||||
/// created collection's id on success, `null` on failure. [itemKind] is
|
||||
/// only used by Plex (it disambiguates the section type — movie/show/
|
||||
/// season/episode); Jellyfin ignores it.
|
||||
Future<String?> createCollection({
|
||||
required String libraryId,
|
||||
required String title,
|
||||
required List<MediaItem> items,
|
||||
MediaKind? itemKind,
|
||||
});
|
||||
|
||||
/// Append [items] to an existing collection.
|
||||
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items});
|
||||
|
||||
/// Remove a single [item] from [collectionId].
|
||||
Future<bool> removeFromCollection({required String collectionId, required MediaItem item});
|
||||
|
||||
/// Delete a collection from the server. The collection is passed as a
|
||||
/// [MediaItem] (kind == [MediaKind.collection]) so the implementation can
|
||||
/// read [MediaItem.libraryId] for backends that need it (Plex).
|
||||
Future<bool> deleteCollection(MediaItem collection);
|
||||
|
||||
// ── Item write ───────────────────────────────────────────────────
|
||||
/// Permanently delete [item] from the library.
|
||||
Future<bool> deleteMediaItem(MediaItem item);
|
||||
|
||||
/// File info (codec / resolution / bitrate / file path) for [item].
|
||||
/// Plex round-trips `/library/metadata/{id}` for the full set; Jellyfin
|
||||
/// reads inline `MediaSources` for the subset it has. Returns `null` if
|
||||
/// the server has no info to show.
|
||||
Future<MediaFileInfo?> getFileInfo(MediaItem item);
|
||||
|
||||
// ── Images ───────────────────────────────────────────────────────
|
||||
/// Resolve a backend-relative thumbnail path to a fully-qualified URL ready
|
||||
/// for `cached_network_image`. Returns an empty string for null/empty
|
||||
/// inputs.
|
||||
///
|
||||
/// When [width]/[height] are provided, the implementation should request
|
||||
/// a server-side resize: Plex builds a `/photo/:/transcode` URL; Jellyfin
|
||||
/// appends `MaxWidth`/`MaxHeight` to the image endpoint.
|
||||
String thumbnailUrl(String? path, {int? width, int? height});
|
||||
|
||||
/// Proxy an absolute external image URL through the server's transcoder
|
||||
/// (Plex `/photo/:/transcode?url=...`). Backends without a proxy endpoint
|
||||
/// (Jellyfin) should return the URL unchanged. Used for EPG provider art
|
||||
/// and other off-server images that benefit from re-encoding.
|
||||
String externalImageUrl(String url, {int? width, int? height});
|
||||
|
||||
/// Headers that must be attached when the player fetches a direct-play
|
||||
/// URL from this server. Plex requires `X-Plex-Token` (and identity
|
||||
/// headers); Jellyfin embeds its `api_key` in the query string and
|
||||
/// returns an empty map. Player code should pass these through to the
|
||||
/// engine alongside the URL.
|
||||
Map<String, String> get streamHeaders;
|
||||
|
||||
// ── External IDs ─────────────────────────────────────────────────
|
||||
/// External IDs (IMDb / TMDB / TVDB) for [itemId]. Plex hits
|
||||
/// `/library/metadata/{id}?includeGuids=1`; Jellyfin reads the inline
|
||||
/// `ProviderIds` map. Returns an empty [ExternalIds] when the server
|
||||
/// has no external mapping for the item.
|
||||
Future<ExternalIds> fetchExternalIds(String itemId);
|
||||
|
||||
// ── Hubs: extras ─────────────────────────────────────────────────
|
||||
/// Chapters and intro/credits markers for [itemId]. Plex returns both
|
||||
/// in one round trip; Jellyfin only has chapters (markers list is
|
||||
/// empty). Implementations may cache.
|
||||
Future<PlaybackExtras> fetchPlaybackExtras(
|
||||
String itemId, {
|
||||
String? introPattern,
|
||||
String? creditsPattern,
|
||||
bool forceRefresh = false,
|
||||
});
|
||||
|
||||
/// Cache-only [PlaybackExtras] read for [itemId]. Used as the offline
|
||||
/// fallback when [fetchPlaybackExtras] cannot reach the network. Returns
|
||||
/// `null` when no row is cached or the row carries no chapter/marker
|
||||
/// data — callers treat that as "no extras available" without surfacing
|
||||
/// an error.
|
||||
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
|
||||
String itemId, {
|
||||
String? introPattern,
|
||||
String? creditsPattern,
|
||||
});
|
||||
|
||||
/// Cache-only [MediaSourceInfo] read for [itemId]. Used by the offline
|
||||
/// playback path to recover audio/subtitle track info (track ids, language
|
||||
/// codes, displayTitles) without hitting the network. Returns `null` when
|
||||
/// the row isn't cached or carries no usable media source.
|
||||
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId);
|
||||
|
||||
/// Build a scrub preview source for [item] using [mediaSource]. Plex
|
||||
/// downloads + parses BIF bytes; Jellyfin assembles a sprite-sheet
|
||||
/// reader from the trickplay manifest. Returns `null` when scrub
|
||||
/// previews aren't available for this item — either because the
|
||||
/// backend doesn't advertise the capability, or the per-item inputs
|
||||
/// are missing (Plex needs `partId`, Jellyfin needs a non-empty
|
||||
/// `trickplayByWidth` map).
|
||||
Future<ScrubPreviewSource?> createScrubPreviewSource({required MediaItem item, required MediaSourceInfo mediaSource});
|
||||
|
||||
// ── Playback progress ────────────────────────────────────────────
|
||||
/// Watched threshold (0.0–1.0). An item is considered "watched" when
|
||||
/// `position / duration` crosses this value. Plex reads it from the
|
||||
/// server's `LibraryVideoPlayedThreshold` pref; Jellyfin doesn't expose
|
||||
/// one and returns a fixed 0.9.
|
||||
double get watchedThreshold;
|
||||
|
||||
/// First playback signal for [itemId]. Plex sends a `/:/timeline?state=playing`
|
||||
/// heartbeat; Jellyfin opens a `/Sessions/Playing` session row. Subsequent
|
||||
/// ticks must call [reportPlaybackProgress] (Jellyfin distinguishes session
|
||||
/// open from progress; Plex treats them identically).
|
||||
///
|
||||
/// [duration] is the media's total length — passed through to Plex's
|
||||
/// timeline param so the server can use it. Jellyfin ignores [duration] but
|
||||
/// uses [mediaSourceId] and stream indexes for active-session state.
|
||||
Future<void> reportPlaybackStarted({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
});
|
||||
|
||||
/// Progress heartbeat after [reportPlaybackStarted]. State is derived from
|
||||
/// [isPaused]. Jellyfin persists remembered audio/subtitle choices from the
|
||||
/// selected stream indexes on this call.
|
||||
Future<void> reportPlaybackProgress({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
bool isPaused = false,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
});
|
||||
|
||||
/// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes
|
||||
/// the session row.
|
||||
Future<void> reportPlaybackStopped({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
String? playSessionId,
|
||||
String? mediaSourceId,
|
||||
});
|
||||
|
||||
// ── Playback initialization ──────────────────────────────────────
|
||||
/// Resolve the video URL, media info, and external subtitle list for
|
||||
/// playback. Backends own the per-backend particulars: Plex runs the
|
||||
/// transcode-decision flow when [PlaybackInitializationOptions.qualityPreset]
|
||||
/// is non-original; Jellyfin always direct-streams. Throws
|
||||
/// [PlaybackException] when the item can't be resolved (no MediaSources,
|
||||
/// no playable URL, transcode decision unavailable).
|
||||
///
|
||||
/// Offline-file substitution is handled centrally in
|
||||
/// `PlaybackInitializationService` — backends always produce online
|
||||
/// metadata, even when the caller intends to play a downloaded copy.
|
||||
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options);
|
||||
|
||||
// ── Live TV ──────────────────────────────────────────────────────
|
||||
/// Backend-neutral live-TV operations. Always returns a wrapper; consult
|
||||
/// [LiveTvSupport.isAvailable] to find out whether the server actually
|
||||
/// has live TV configured before calling other methods.
|
||||
LiveTvSupport get liveTv;
|
||||
|
||||
// ── Downloads ────────────────────────────────────────────────────
|
||||
/// Resolve the download URL for [item]'s primary video file along with
|
||||
/// any external subtitle tracks that should be saved alongside it.
|
||||
///
|
||||
/// [mediaIndex] selects among multiple media versions when an item has
|
||||
/// them (Plex only — Jellyfin returns the same file regardless).
|
||||
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0});
|
||||
|
||||
/// The artwork files the download pipeline should persist for [item] so
|
||||
/// the offline UI can render its poster, clear logo, and background art.
|
||||
/// Each entry pairs the absolute URL with a stable `localKey` the
|
||||
/// storage service hashes to deduplicate across items that share blobs.
|
||||
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item);
|
||||
|
||||
/// Resolve a fully-qualified URL the OS-level external player (VLC, Infuse,
|
||||
/// MX Player, etc.) can fetch directly. Plex builds this from the chosen
|
||||
/// media version's part path; Jellyfin returns its `/Videos/{id}/stream`
|
||||
/// endpoint with `Static=true` so transcoding is bypassed. Returns null
|
||||
/// when the backend can't resolve a playable URL for the item.
|
||||
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0});
|
||||
}
|
||||
|
||||
/// Optional interface for backends whose public server id is not specific
|
||||
/// enough for user-scoped local state.
|
||||
abstract interface class ScopedMediaServerClient {
|
||||
String get scopedServerId;
|
||||
}
|
||||
|
||||
extension MediaServerClientScope on MediaServerClient {
|
||||
/// Internal cache/sync namespace. Most backends use [serverId]; Jellyfin
|
||||
/// overrides this with its compound `{machineId}/{userId}` connection id so
|
||||
/// per-user `UserData` and queued progress never bleed across profiles.
|
||||
String get cacheServerId => switch (this) {
|
||||
ScopedMediaServerClient(:final scopedServerId) => scopedServerId,
|
||||
_ => serverId,
|
||||
};
|
||||
}
|
||||
|
||||
/// Cache-aware fetch helpers shared by both backends so the offline-first /
|
||||
/// network-then-cache pattern lives in one place.
|
||||
///
|
||||
/// Originally a Plex-only inline helper; lifted into a mixin so [JellyfinClient]
|
||||
/// can stop reimplementing it (and gets the missing "fall back to cache on
|
||||
/// non-network errors" branch). Mixed onto concrete [MediaServerClient]
|
||||
/// implementations — both clients use `implements MediaServerClient` so a
|
||||
/// shared base class isn't an option, but a `mixin on MediaServerClient` is.
|
||||
mixin MediaServerCacheMixin implements MediaServerClient {
|
||||
/// Fetch with cache fallback: offline → cached only; online → try network,
|
||||
/// cache the result, fall back to cached on any error.
|
||||
///
|
||||
/// Returns `null` when offline mode is on and no cached row exists, or
|
||||
/// when both network and cache come up empty.
|
||||
Future<T?> fetchWithCacheFallback<T>({
|
||||
required String cacheKey,
|
||||
required Future<MediaServerResponse> Function() networkCall,
|
||||
required T? Function(dynamic cachedData) parseCache,
|
||||
required T? Function(MediaServerResponse response) parseResponse,
|
||||
bool cacheResponse = true,
|
||||
}) async {
|
||||
if (isOfflineMode) {
|
||||
final cached = await cache.get(cacheServerId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final response = await networkCall();
|
||||
if (cacheResponse) await _putCacheResponse(cacheKey, response.data);
|
||||
return parseResponse(response);
|
||||
} catch (e) {
|
||||
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
|
||||
final cached = await cache.get(cacheServerId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first fetch: serve from cache when available, hit the network
|
||||
/// only on miss. Use when freshness is non-critical and prior fetches are
|
||||
/// likely to have populated the cache (e.g. playback after the detail
|
||||
/// screen pre-warmed the row).
|
||||
Future<T?> fetchWithCacheFirst<T>({
|
||||
required String cacheKey,
|
||||
required Future<MediaServerResponse> Function() networkCall,
|
||||
required T? Function(dynamic cachedData) parseCache,
|
||||
required T? Function(MediaServerResponse response) parseResponse,
|
||||
bool cacheResponse = true,
|
||||
}) async {
|
||||
final cached = await cache.get(cacheServerId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
if (isOfflineMode) return null;
|
||||
final response = await networkCall();
|
||||
if (cacheResponse) await _putCacheResponse(cacheKey, response.data);
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
Future<void> _putCacheResponse(String cacheKey, dynamic data) async {
|
||||
if (data is Map<String, dynamic>) {
|
||||
await cache.put(cacheServerId, cacheKey, data);
|
||||
} else if (data != null) {
|
||||
appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// Backend-neutral subtitle playback modes used by server-side user profiles.
|
||||
enum SubtitlePlaybackMode {
|
||||
none,
|
||||
defaultMode,
|
||||
always,
|
||||
onlyForced,
|
||||
smart;
|
||||
|
||||
static SubtitlePlaybackMode? fromServerValue(Object? value) {
|
||||
final normalized = value?.toString().trim().toLowerCase();
|
||||
return switch (normalized) {
|
||||
'none' => SubtitlePlaybackMode.none,
|
||||
'default' => SubtitlePlaybackMode.defaultMode,
|
||||
'always' => SubtitlePlaybackMode.always,
|
||||
'onlyforced' => SubtitlePlaybackMode.onlyForced,
|
||||
'smart' => SubtitlePlaybackMode.smart,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-neutral subset of a server-stored user profile, scoped to the
|
||||
/// fields the player needs for auto-track selection. Each backend exposes
|
||||
/// these on its own concrete type ([PlexUserProfile], [JellyfinUserProfile]).
|
||||
///
|
||||
/// Language strings are server-shaped (Plex returns 639-2/B like "fre",
|
||||
/// Jellyfin returns 639-2/T like "fra"); [LanguageCodes.getVariations]
|
||||
/// handles either when matching against mpv-reported track languages.
|
||||
abstract class MediaServerUserProfile {
|
||||
/// Whether the player should auto-pick an audio track based on the
|
||||
/// language preferences. False means "keep the file's default track".
|
||||
bool get autoSelectAudio;
|
||||
|
||||
/// Primary preferred audio language. May be null when the user has no
|
||||
/// preference set.
|
||||
String? get defaultAudioLanguage;
|
||||
|
||||
/// Additional ranked audio language preferences. Plex exposes a list,
|
||||
/// Jellyfin only the primary; Jellyfin implementations return null.
|
||||
List<String>? get defaultAudioLanguages;
|
||||
|
||||
/// Primary preferred subtitle language. May be null.
|
||||
String? get defaultSubtitleLanguage;
|
||||
|
||||
/// Additional ranked subtitle language preferences. Same Plex/Jellyfin
|
||||
/// difference as the audio list.
|
||||
List<String>? get defaultSubtitleLanguages;
|
||||
|
||||
/// Server-side subtitle mode when exposed by the backend. Plex does not map
|
||||
/// cleanly to Jellyfin's mode enum, so it returns null and keeps existing
|
||||
/// Plex-selected-stream behavior.
|
||||
SubtitlePlaybackMode? get subtitleMode => null;
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'plex_sort.g.dart';
|
||||
part 'media_sort.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class PlexSort {
|
||||
class MediaSort {
|
||||
final String key;
|
||||
final String? descKey;
|
||||
final String title;
|
||||
final String? defaultDirection;
|
||||
|
||||
PlexSort({required this.key, this.descKey, required this.title, this.defaultDirection});
|
||||
MediaSort({required this.key, this.descKey, required this.title, this.defaultDirection});
|
||||
|
||||
factory PlexSort.fromJson(Map<String, dynamic> json) => _$PlexSortFromJson(json);
|
||||
factory MediaSort.fromJson(Map<String, dynamic> json) => _$MediaSortFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$PlexSortToJson(this);
|
||||
Map<String, dynamic> toJson() => _$MediaSortToJson(this);
|
||||
|
||||
/// Gets the full sort key with direction
|
||||
/// If [descending] is true, returns the descKey or key:desc
|
||||
@@ -34,13 +34,13 @@ class PlexSort {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlexSort(key: $key, title: $title, defaultDirection: $defaultDirection)';
|
||||
return 'MediaSort(key: $key, title: $title, defaultDirection: $defaultDirection)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is PlexSort && other.key == key;
|
||||
return other is MediaSort && other.key == key;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1,19 +1,19 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_sort.dart';
|
||||
part of 'media_sort.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexSort _$PlexSortFromJson(Map<String, dynamic> json) => PlexSort(
|
||||
MediaSort _$MediaSortFromJson(Map<String, dynamic> json) => MediaSort(
|
||||
key: json['key'] as String,
|
||||
descKey: json['descKey'] as String?,
|
||||
title: json['title'] as String,
|
||||
defaultDirection: json['defaultDirection'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexSortToJson(PlexSort instance) => <String, dynamic>{
|
||||
Map<String, dynamic> _$MediaSortToJson(MediaSort instance) => <String, dynamic>{
|
||||
'key': instance.key,
|
||||
'descKey': instance.descKey,
|
||||
'title': instance.title,
|
||||
@@ -1,95 +1,69 @@
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/track_label_builder.dart' show buildTrackLabel;
|
||||
|
||||
class PlexMediaInfo {
|
||||
class MediaSourceInfo {
|
||||
final String videoUrl;
|
||||
final List<PlexAudioTrack> audioTracks;
|
||||
final List<PlexSubtitleTrack> subtitleTracks;
|
||||
final List<PlexChapter> chapters;
|
||||
final List<MediaAudioTrack> audioTracks;
|
||||
final List<MediaSubtitleTrack> subtitleTracks;
|
||||
final List<MediaChapter> chapters;
|
||||
final int? partId;
|
||||
final double? frameRate;
|
||||
|
||||
PlexMediaInfo({
|
||||
/// Jellyfin source id for the *selected* version (null on Plex). Lets the
|
||||
/// trickplay loader request the right tile sheet when an item has multiple
|
||||
/// `MediaSources`.
|
||||
final String? mediaSourceId;
|
||||
|
||||
/// Jellyfin default stream indexes for this source. A subtitle index of -1
|
||||
/// is an explicit server/user decision to start with subtitles off.
|
||||
final int? defaultAudioStreamIndex;
|
||||
final int? defaultSubtitleStreamIndex;
|
||||
|
||||
/// Jellyfin trickplay manifest for the selected source, keyed by tile
|
||||
/// width. Null when the server didn't run trickplay extraction. Plex stays
|
||||
/// null here and uses [partId] + the BIF service instead.
|
||||
final Map<int, TrickplayInfo>? trickplayByWidth;
|
||||
|
||||
MediaSourceInfo({
|
||||
required this.videoUrl,
|
||||
required this.audioTracks,
|
||||
required this.subtitleTracks,
|
||||
required this.chapters,
|
||||
this.partId,
|
||||
this.frameRate,
|
||||
this.mediaSourceId,
|
||||
this.defaultAudioStreamIndex,
|
||||
this.defaultSubtitleStreamIndex,
|
||||
this.trickplayByWidth,
|
||||
});
|
||||
int? getPartId() => partId;
|
||||
}
|
||||
|
||||
/// Creates a [PlexMediaInfo] from cached metadata JSON (as stored by [PlexApiCache]).
|
||||
/// Parses audio/subtitle tracks from `Media[0].Part[0].Stream[]` so that
|
||||
/// offline playback can still apply language-based track selection.
|
||||
static PlexMediaInfo? fromMetadataJson(Map<String, dynamic> metadata) {
|
||||
final media = flexibleList(metadata['Media']);
|
||||
if (media == null || media.isEmpty) return null;
|
||||
final parts = flexibleList(media.first['Part']);
|
||||
if (parts == null || parts.isEmpty) return null;
|
||||
final streams = flexibleList(parts.first['Stream']);
|
||||
/// Per-resolution Jellyfin trickplay manifest. Mirrors `TrickplayInfoDto`
|
||||
/// from the Jellyfin OpenAPI spec.
|
||||
class TrickplayInfo {
|
||||
final int width;
|
||||
final int height;
|
||||
final int tileWidth;
|
||||
final int tileHeight;
|
||||
final int thumbnailCount;
|
||||
final int interval;
|
||||
final int bandwidth;
|
||||
|
||||
final audioTracks = <PlexAudioTrack>[];
|
||||
final subtitleTracks = <PlexSubtitleTrack>[];
|
||||
double? frameRate;
|
||||
|
||||
if (streams != null) {
|
||||
for (final s in streams) {
|
||||
try {
|
||||
final streamType = s['streamType'] as int?;
|
||||
if (streamType == 1) {
|
||||
frameRate ??= (s['frameRate'] as num?)?.toDouble();
|
||||
} else if (streamType == 2) {
|
||||
audioTracks.add(
|
||||
PlexAudioTrack(
|
||||
id: s['id'] as int,
|
||||
index: s['index'] as int?,
|
||||
codec: s['codec'] as String?,
|
||||
language: s['language'] as String?,
|
||||
languageCode: s['languageCode'] as String?,
|
||||
title: s['title'] as String?,
|
||||
displayTitle: s['displayTitle'] as String?,
|
||||
channels: s['channels'] as int?,
|
||||
selected: flexibleBool(s['selected']),
|
||||
),
|
||||
);
|
||||
} else if (streamType == 3) {
|
||||
subtitleTracks.add(
|
||||
PlexSubtitleTrack(
|
||||
id: s['id'] as int,
|
||||
index: s['index'] as int?,
|
||||
codec: s['codec'] as String?,
|
||||
language: s['language'] as String?,
|
||||
languageCode: s['languageCode'] as String?,
|
||||
title: s['title'] as String?,
|
||||
displayTitle: s['displayTitle'] as String?,
|
||||
selected: flexibleBool(s['selected']),
|
||||
forced: flexibleBool(s['forced']),
|
||||
key: s['key'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.d('Skipping malformed stream in cached metadata', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PlexMediaInfo(
|
||||
videoUrl: '',
|
||||
audioTracks: audioTracks,
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: const [],
|
||||
frameRate: frameRate,
|
||||
);
|
||||
}
|
||||
const TrickplayInfo({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.tileWidth,
|
||||
required this.tileHeight,
|
||||
required this.thumbnailCount,
|
||||
required this.interval,
|
||||
required this.bandwidth,
|
||||
});
|
||||
}
|
||||
|
||||
/// Mixin for building track labels with a consistent pattern.
|
||||
///
|
||||
/// Used by [PlexAudioTrack] and [PlexSubtitleTrack] to provide a [buildLabel]
|
||||
/// Used by [MediaAudioTrack] and [MediaSubtitleTrack] to provide a [buildLabel]
|
||||
/// method that delegates to the shared [buildTrackLabel] function.
|
||||
mixin _TrackLabelMixin {
|
||||
int get id;
|
||||
@@ -108,7 +82,7 @@ mixin _TrackLabelMixin {
|
||||
}
|
||||
}
|
||||
|
||||
class PlexAudioTrack with _TrackLabelMixin {
|
||||
class MediaAudioTrack with _TrackLabelMixin {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
@@ -123,7 +97,7 @@ class PlexAudioTrack with _TrackLabelMixin {
|
||||
final int? channels;
|
||||
final bool selected;
|
||||
|
||||
PlexAudioTrack({
|
||||
MediaAudioTrack({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.codec,
|
||||
@@ -143,7 +117,7 @@ class PlexAudioTrack with _TrackLabelMixin {
|
||||
}
|
||||
}
|
||||
|
||||
class PlexSubtitleTrack with _TrackLabelMixin {
|
||||
class MediaSubtitleTrack with _TrackLabelMixin {
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
@@ -158,8 +132,9 @@ class PlexSubtitleTrack with _TrackLabelMixin {
|
||||
final bool selected;
|
||||
final bool forced;
|
||||
final String? key;
|
||||
final bool external;
|
||||
|
||||
PlexSubtitleTrack({
|
||||
MediaSubtitleTrack({
|
||||
required this.id,
|
||||
this.index,
|
||||
this.codec,
|
||||
@@ -170,6 +145,7 @@ class PlexSubtitleTrack with _TrackLabelMixin {
|
||||
required this.selected,
|
||||
required this.forced,
|
||||
this.key,
|
||||
this.external = false,
|
||||
});
|
||||
|
||||
String get label {
|
||||
@@ -178,30 +154,13 @@ class PlexSubtitleTrack with _TrackLabelMixin {
|
||||
return buildLabel(additionalParts);
|
||||
}
|
||||
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle)
|
||||
/// External subtitles have a key property that points to /library/streams/{id}
|
||||
bool get isExternal => key != null && key!.isNotEmpty;
|
||||
|
||||
/// Constructs the full URL for fetching external subtitle files
|
||||
/// Returns null if this is not an external subtitle
|
||||
String? getSubtitleUrl(String baseUrl, String token) {
|
||||
if (!isExternal) return null;
|
||||
final ext = CodecUtils.getSubtitleExtension(codec);
|
||||
return '$baseUrl$key.$ext?encoding=utf-8&X-Plex-Token=$token';
|
||||
}
|
||||
|
||||
/// Constructs a sidecar URL for any subtitle track (internal or external),
|
||||
/// used in transcode mode where embedded subtitle streams are stripped.
|
||||
/// Falls back to the standard `/library/streams/{id}.{ext}` path when
|
||||
/// [key] is missing.
|
||||
String getTranscodeSidecarUrl(String baseUrl, String token) {
|
||||
final ext = CodecUtils.getSubtitleExtension(codec);
|
||||
final path = (key != null && key!.isNotEmpty) ? key! : '/library/streams/$id';
|
||||
return '$baseUrl$path.$ext?encoding=utf-8&X-Plex-Token=$token';
|
||||
}
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle).
|
||||
/// Some backends provide a direct key/URL, others require constructing one
|
||||
/// from stream metadata.
|
||||
bool get isExternal => external || (key != null && key!.isNotEmpty);
|
||||
}
|
||||
|
||||
class PlexChapter {
|
||||
class MediaChapter {
|
||||
final int id;
|
||||
final int? index;
|
||||
final int? startTimeOffset;
|
||||
@@ -209,7 +168,26 @@ class PlexChapter {
|
||||
final String? title;
|
||||
final String? thumb;
|
||||
|
||||
PlexChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb});
|
||||
MediaChapter({required this.id, this.index, this.startTimeOffset, this.endTimeOffset, this.title, this.thumb});
|
||||
|
||||
/// Backfill missing `endTimeOffset` on each chapter from the next chapter's
|
||||
/// `startTimeOffset`. Jellyfin sends only starts; the seek-bar tick UI needs
|
||||
/// duration ranges. Mutates [chapters] in place and returns it.
|
||||
static List<MediaChapter> backfillEndOffsets(List<MediaChapter> chapters) {
|
||||
for (var i = 0; i < chapters.length - 1; i++) {
|
||||
final c = chapters[i];
|
||||
if (c.endTimeOffset != null) continue;
|
||||
chapters[i] = MediaChapter(
|
||||
id: c.id,
|
||||
index: c.index,
|
||||
startTimeOffset: c.startTimeOffset,
|
||||
endTimeOffset: chapters[i + 1].startTimeOffset,
|
||||
title: c.title,
|
||||
thumb: c.thumb,
|
||||
);
|
||||
}
|
||||
return chapters;
|
||||
}
|
||||
|
||||
String get label => title ?? 'Chapter ${(index ?? 0) + 1}';
|
||||
|
||||
@@ -219,7 +197,7 @@ class PlexChapter {
|
||||
/// Find the chapter index containing [position]. Returns null if none match.
|
||||
/// A chapter's end defaults to the next chapter's start when [endTimeOffset]
|
||||
/// is missing; the final chapter without an end extends to infinity.
|
||||
static int? indexAtPosition(Duration position, List<PlexChapter> chapters) {
|
||||
static int? indexAtPosition(Duration position, List<MediaChapter> chapters) {
|
||||
final positionMs = position.inMilliseconds;
|
||||
for (int i = 0; i < chapters.length; i++) {
|
||||
final chapter = chapters[i];
|
||||
@@ -233,13 +211,13 @@ class PlexChapter {
|
||||
}
|
||||
}
|
||||
|
||||
class PlexMarker {
|
||||
class MediaMarker {
|
||||
final int id;
|
||||
final String type;
|
||||
final int startTimeOffset;
|
||||
final int endTimeOffset;
|
||||
|
||||
PlexMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset});
|
||||
MediaMarker({required this.id, required this.type, required this.startTimeOffset, required this.endTimeOffset});
|
||||
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
@@ -255,8 +233,8 @@ class PlexMarker {
|
||||
|
||||
/// Combined chapters and markers fetched in a single API call
|
||||
class PlaybackExtras {
|
||||
final List<PlexChapter> chapters;
|
||||
final List<PlexMarker> markers;
|
||||
final List<MediaChapter> chapters;
|
||||
final List<MediaMarker> markers;
|
||||
|
||||
PlaybackExtras({required this.chapters, required this.markers});
|
||||
|
||||
@@ -271,8 +249,8 @@ class PlaybackExtras {
|
||||
/// When real markers exist, reclassifies markers with unknown types against
|
||||
/// the patterns so non-standard type strings (e.g. "OP-Song") get recognized.
|
||||
factory PlaybackExtras.withChapterFallback({
|
||||
required List<PlexChapter> chapters,
|
||||
required List<PlexMarker> markers,
|
||||
required List<MediaChapter> chapters,
|
||||
required List<MediaMarker> markers,
|
||||
String? introPatternStr,
|
||||
String? creditsPatternStr,
|
||||
}) {
|
||||
@@ -291,7 +269,7 @@ class PlaybackExtras {
|
||||
if (m.type == 'intro' || m.type == 'credits') return m;
|
||||
final newType = _classifyChapterTitle(m.type, introPattern, creditsPattern);
|
||||
if (newType != null) {
|
||||
return PlexMarker(
|
||||
return MediaMarker(
|
||||
id: m.id,
|
||||
type: newType,
|
||||
startTimeOffset: m.startTimeOffset,
|
||||
@@ -303,7 +281,7 @@ class PlaybackExtras {
|
||||
return PlaybackExtras(chapters: chapters, markers: reclassified);
|
||||
}
|
||||
|
||||
final synthetic = <PlexMarker>[];
|
||||
final synthetic = <MediaMarker>[];
|
||||
for (var i = 0; i < chapters.length; i++) {
|
||||
final ch = chapters[i];
|
||||
final title = ch.title;
|
||||
@@ -318,7 +296,7 @@ class PlaybackExtras {
|
||||
final end = ch.endTimeOffset ?? (i + 1 < chapters.length ? chapters[i + 1].startTimeOffset : null);
|
||||
if (end == null) continue;
|
||||
|
||||
synthetic.add(PlexMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
|
||||
synthetic.add(MediaMarker(id: ch.id, type: type, startTimeOffset: start, endTimeOffset: end));
|
||||
}
|
||||
|
||||
return PlaybackExtras(chapters: chapters, markers: synthetic);
|
||||
@@ -0,0 +1,49 @@
|
||||
/// Type of an embedded or sidecar stream within a media file.
|
||||
enum MediaStreamKind { video, audio, subtitle, unknown }
|
||||
|
||||
/// A single audio, video, or subtitle stream inside a media part.
|
||||
class MediaStream {
|
||||
/// Backend-opaque stream identifier.
|
||||
final String id;
|
||||
final MediaStreamKind kind;
|
||||
final int? index;
|
||||
final String? codec;
|
||||
final String? language;
|
||||
final String? languageCode;
|
||||
final String? title;
|
||||
final String? displayTitle;
|
||||
final bool selected;
|
||||
|
||||
// Audio
|
||||
final int? channels;
|
||||
|
||||
// Video
|
||||
final double? frameRate;
|
||||
|
||||
// Subtitle
|
||||
final bool forced;
|
||||
|
||||
/// Backend-resolved location for sidecar subtitle download. For Plex this
|
||||
/// is the Plex-specific `/library/streams/{id}` path; for Jellyfin this is
|
||||
/// the `DeliveryUrl` returned by `/Items/{id}/PlaybackInfo`. Null for
|
||||
/// embedded streams.
|
||||
final String? sidecarPath;
|
||||
|
||||
const MediaStream({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
this.index,
|
||||
this.codec,
|
||||
this.language,
|
||||
this.languageCode,
|
||||
this.title,
|
||||
this.displayTitle,
|
||||
this.selected = false,
|
||||
this.channels,
|
||||
this.frameRate,
|
||||
this.forced = false,
|
||||
this.sidecarPath,
|
||||
});
|
||||
|
||||
bool get isExternal => sidecarPath != null && sidecarPath!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import 'media_part.dart';
|
||||
|
||||
/// Convert backend bitrates reported in bits-per-second to app-standard kbps.
|
||||
int? bitrateKbpsFromBps(int? bps) {
|
||||
if (bps == null || bps <= 0) return null;
|
||||
return (bps / 1000).round();
|
||||
}
|
||||
|
||||
/// A single media variant available for an item — represents one quality level
|
||||
/// or transcode profile of the underlying file. An item with multiple versions
|
||||
/// (e.g. 4K + 1080p re-encode) exposes one [MediaVersion] per option.
|
||||
class MediaVersion {
|
||||
/// Backend-opaque version identifier.
|
||||
final String id;
|
||||
final int? width;
|
||||
final int? height;
|
||||
final String? videoResolution; // "1080", "4k", "sd"
|
||||
final String? videoCodec;
|
||||
final int? bitrate;
|
||||
final String? container;
|
||||
final List<MediaPart> parts;
|
||||
|
||||
/// Human-readable name for this version (e.g. "Director's Cut").
|
||||
/// Plex doesn't surface a name on `Media` entries, so this is null on the
|
||||
/// Plex path and set from `MediaSource.Name` on the Jellyfin path when the
|
||||
/// names differ across sources.
|
||||
final String? name;
|
||||
|
||||
const MediaVersion({
|
||||
required this.id,
|
||||
this.width,
|
||||
this.height,
|
||||
this.videoResolution,
|
||||
this.videoCodec,
|
||||
this.bitrate,
|
||||
this.container,
|
||||
this.parts = const [],
|
||||
this.name,
|
||||
});
|
||||
|
||||
/// Defaults to true when file-access fields are absent. Plex only populates
|
||||
/// them when metadata is fetched with `checkFiles=1`.
|
||||
bool get isPlayable => parts.isEmpty || parts.first.isPlayable;
|
||||
|
||||
/// Display label with detailed information: "1080p H.264 MKV (8.5 Mbps)".
|
||||
/// When [name] is set, it prefixes the technical label so a user can tell
|
||||
/// "Director's Cut · 1080p H.264 MKV" apart from "Theatrical Cut · 1080p
|
||||
/// H.264 MKV" when the underlying tech specs collide.
|
||||
String get displayLabel {
|
||||
final parts = <String>[];
|
||||
|
||||
if (videoResolution != null && videoResolution!.isNotEmpty) {
|
||||
parts.add('${videoResolution}p');
|
||||
} else if (height != null) {
|
||||
parts.add('${height}p');
|
||||
}
|
||||
|
||||
if (videoCodec != null && videoCodec!.isNotEmpty) {
|
||||
parts.add(CodecUtils.formatVideoCodec(videoCodec!));
|
||||
}
|
||||
|
||||
if (container != null && container!.isNotEmpty) {
|
||||
parts.add(container!.toUpperCase());
|
||||
}
|
||||
|
||||
String label = parts.isNotEmpty ? parts.join(' ') : 'Unknown';
|
||||
|
||||
if (bitrate != null && bitrate! > 0) {
|
||||
label += ' (${ByteFormatter.formatBitrate(bitrate!)})';
|
||||
}
|
||||
|
||||
if (name != null && name!.isNotEmpty) {
|
||||
return parts.isEmpty && (bitrate == null || bitrate! <= 0) ? name! : '$name · $label';
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
/// Version signature used for matching equivalent versions across episodes.
|
||||
/// Format: "resolution:codec:container".
|
||||
String get signature {
|
||||
final res = videoResolution ?? '';
|
||||
final codec = videoCodec ?? '';
|
||||
final cont = container ?? '';
|
||||
return '$res:$codec:$cont'.toLowerCase();
|
||||
}
|
||||
|
||||
String get _resolutionPart => (videoResolution ?? '').toLowerCase();
|
||||
String get _codecPart => (videoCodec ?? '').toLowerCase();
|
||||
|
||||
/// Find the best matching version index from a set of accepted signatures.
|
||||
/// Tier 1: exact match. Tier 2: resolution+codec. Tier 3: resolution only.
|
||||
/// Returns null if no accepted signature matches.
|
||||
static int? findMatchingIndex(List<MediaVersion> versions, Set<String> acceptedSignatures) {
|
||||
if (versions.isEmpty || acceptedSignatures.isEmpty) return null;
|
||||
|
||||
for (final sig in acceptedSignatures) {
|
||||
final parts = sig.split(':');
|
||||
if (parts.length != 3) continue;
|
||||
final targetRes = parts[0];
|
||||
final targetCodec = parts[1];
|
||||
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i].signature == sig) return i;
|
||||
}
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i;
|
||||
}
|
||||
for (int i = 0; i < versions.length; i++) {
|
||||
if (versions[i]._resolutionPart == targetRes) return i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'media_item.dart';
|
||||
|
||||
/// Backend-neutral play queue — a flat ordered list of items with a current
|
||||
/// cursor. Implementations differ in whether the queue is server-resourced
|
||||
/// (Plex) or client-only (Jellyfin).
|
||||
sealed class PlayQueue {
|
||||
/// Items in playback order.
|
||||
List<MediaItem> get items;
|
||||
|
||||
/// Index of the currently-playing item, or `null` if the queue has not
|
||||
/// started.
|
||||
int? get currentIndex;
|
||||
|
||||
/// Whether the queue has been shuffled.
|
||||
bool get shuffled;
|
||||
|
||||
/// Backend that minted this queue.
|
||||
String get backendId;
|
||||
|
||||
MediaItem? get current =>
|
||||
currentIndex != null && currentIndex! >= 0 && currentIndex! < items.length ? items[currentIndex!] : null;
|
||||
|
||||
bool get hasNext => currentIndex != null && currentIndex! + 1 < items.length;
|
||||
bool get hasPrevious => currentIndex != null && currentIndex! > 0;
|
||||
}
|
||||
|
||||
/// Plex play queue — coordinated server-side via `/playQueues` so multiple
|
||||
/// devices can view/control the same queue.
|
||||
class PlexServerPlayQueue extends PlayQueue {
|
||||
/// Plex `playQueueID` — addresses the queue for subsequent fetches.
|
||||
final int playQueueId;
|
||||
|
||||
@override
|
||||
final List<MediaItem> items;
|
||||
|
||||
@override
|
||||
final int? currentIndex;
|
||||
|
||||
@override
|
||||
final bool shuffled;
|
||||
|
||||
/// Plex `playQueueSelectedItemID` of the active item.
|
||||
final int? selectedItemId;
|
||||
|
||||
/// Plex `playQueueVersion` — server-side optimistic concurrency token.
|
||||
final int? version;
|
||||
|
||||
/// Plex `playQueueSourceURI` — used for "Up Next" derivation.
|
||||
final String? sourceUri;
|
||||
|
||||
PlexServerPlayQueue({
|
||||
required this.playQueueId,
|
||||
required this.items,
|
||||
this.currentIndex,
|
||||
this.shuffled = false,
|
||||
this.selectedItemId,
|
||||
this.version,
|
||||
this.sourceUri,
|
||||
});
|
||||
|
||||
@override
|
||||
String get backendId => 'plex';
|
||||
|
||||
PlexServerPlayQueue copyWith({
|
||||
int? playQueueId,
|
||||
List<MediaItem>? items,
|
||||
int? currentIndex,
|
||||
bool? shuffled,
|
||||
int? selectedItemId,
|
||||
int? version,
|
||||
String? sourceUri,
|
||||
}) {
|
||||
return PlexServerPlayQueue(
|
||||
playQueueId: playQueueId ?? this.playQueueId,
|
||||
items: items ?? this.items,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
shuffled: shuffled ?? this.shuffled,
|
||||
selectedItemId: selectedItemId ?? this.selectedItemId,
|
||||
version: version ?? this.version,
|
||||
sourceUri: sourceUri ?? this.sourceUri,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-only play queue used by Jellyfin and any backend without a
|
||||
/// server-side queue concept. Each [LocalPlayQueue] is anchored by a
|
||||
/// client-generated UUID so callers can address it like a Plex queue.
|
||||
class LocalPlayQueue extends PlayQueue {
|
||||
/// Client-generated UUID identifying this queue for the session.
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final List<MediaItem> items;
|
||||
|
||||
@override
|
||||
final int? currentIndex;
|
||||
|
||||
@override
|
||||
final bool shuffled;
|
||||
|
||||
/// Server kind that owns this queue's items (typically `"jellyfin"`).
|
||||
@override
|
||||
final String backendId;
|
||||
|
||||
LocalPlayQueue({
|
||||
required this.id,
|
||||
required this.items,
|
||||
required this.backendId,
|
||||
this.currentIndex,
|
||||
this.shuffled = false,
|
||||
});
|
||||
|
||||
LocalPlayQueue copyWith({String? id, List<MediaItem>? items, int? currentIndex, bool? shuffled, String? backendId}) {
|
||||
return LocalPlayQueue(
|
||||
id: id ?? this.id,
|
||||
items: items ?? this.items,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
shuffled: shuffled ?? this.shuffled,
|
||||
backendId: backendId ?? this.backendId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/// How the alpha-jump bar behaves for libraries on this backend.
|
||||
enum AlphaBarMode {
|
||||
/// No alpha bar — hide entirely.
|
||||
none,
|
||||
|
||||
/// Plex: server reports per-letter cumulative offsets via `/firstCharacter`,
|
||||
/// taps scroll the grid to the offset.
|
||||
scrollSnap,
|
||||
|
||||
/// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query
|
||||
/// param, results re-fetch.
|
||||
nameStartsWithFilter,
|
||||
}
|
||||
|
||||
/// Static capability flags advertised by a [MediaServerClient]. UI consults
|
||||
/// these to gate feature affordances per server (e.g. hide Live TV when no
|
||||
/// connected server supports it).
|
||||
///
|
||||
/// These describe what the *backend kind* supports in this app's current
|
||||
/// implementation — not necessarily what the wire protocol can do. As more
|
||||
/// Jellyfin features are wired in over time, the corresponding flags flip
|
||||
/// without changing call sites.
|
||||
class ServerCapabilities {
|
||||
/// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared
|
||||
/// queue state across devices and Watch Together coordination.
|
||||
final bool serverSidePlayQueue;
|
||||
|
||||
/// Server-side editable playlists (Plex `/playlists`, Jellyfin
|
||||
/// `/Playlists`).
|
||||
final bool serverSidePlaylists;
|
||||
|
||||
/// This backend kind has a Live TV / DVR API the app can talk to. Whether
|
||||
/// a *specific* server has Live TV configured is a runtime concern —
|
||||
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and
|
||||
/// only those with channels surface in [MultiServerProvider.liveTvServers].
|
||||
final bool liveTv;
|
||||
|
||||
/// Server has DVR/recording lineups (Plex `/livetv/dvrs`). Channel listing
|
||||
/// is gated by [liveTv]; this flag enables the additional recordings/scheduling
|
||||
/// UI. Jellyfin's DVR API isn't wired in this app yet, so it stays false even
|
||||
/// when [liveTv] is true.
|
||||
final bool liveTvDvr;
|
||||
|
||||
/// Server proxies subtitle search (e.g. OpenSubtitles).
|
||||
final bool subtitleSearch;
|
||||
|
||||
/// Server can transcode video.
|
||||
final bool videoTranscoding;
|
||||
|
||||
/// Server supports server-side downloads / "sync" (the queued-from-server
|
||||
/// model). Both Plex and Jellyfin support client-driven downloads, which
|
||||
/// is a separate concept.
|
||||
final bool serverSideSync;
|
||||
|
||||
/// Server provides curated recommendation hubs (Plex Discover). Jellyfin
|
||||
/// returns synthesized hubs but with sparser categorisation.
|
||||
final bool richHubs;
|
||||
|
||||
/// Numeric ratings (Plex 0–10 via [Item.userRating]). Jellyfin offers
|
||||
/// only a binary like/dislike, so star sliders should be hidden.
|
||||
final bool numericUserRating;
|
||||
|
||||
/// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`).
|
||||
/// Hides the "Search subtitles" affordance when false.
|
||||
final bool externalSubtitleSearch;
|
||||
|
||||
/// Persisting per-track audio/subtitle preferences server-side. Plex uses
|
||||
/// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected
|
||||
/// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin
|
||||
/// remember-selection settings are enabled. When false, in-player switching
|
||||
/// still works but choices don't follow the user across devices.
|
||||
final bool trackPreferencePersistence;
|
||||
|
||||
/// Multi-endpoint connection model with relay/local/remote racing
|
||||
/// (Plex `findBestWorkingConnection`). Jellyfin servers expose a single
|
||||
/// URL, so this is false there.
|
||||
final bool endpointFailover;
|
||||
|
||||
/// Watch progress can be queued offline and replayed when reconnected
|
||||
/// ([OfflineWatchSyncService]). Jellyfin reports inline only today.
|
||||
final bool offlineWatchQueue;
|
||||
|
||||
/// Discord rich-presence integration. Plex-only because the RPC payload
|
||||
/// uses Plex-shaped session/metadata.
|
||||
final bool discordRpc;
|
||||
|
||||
/// Server exposes a metadata edit endpoint (Plex
|
||||
/// `/library/metadata/{id}` PUT). Hides the "Manage" affordances when
|
||||
/// false.
|
||||
final bool richMetadataEdit;
|
||||
|
||||
/// How the alpha-jump bar should behave for this backend's libraries.
|
||||
final AlphaBarMode alphaBar;
|
||||
|
||||
/// Server can supply thumbnails for the player's seek-bar scrub preview.
|
||||
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
|
||||
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
|
||||
/// gates whether the player attempts the load at all.
|
||||
final bool scrubThumbnails;
|
||||
|
||||
/// Library section exposes a folder hierarchy (Plex
|
||||
/// `/library/sections/{id}/folders`). Jellyfin has no equivalent endpoint,
|
||||
/// so the "Folders" grouping option is hidden when this is false.
|
||||
final bool folderGrouping;
|
||||
|
||||
const ServerCapabilities({
|
||||
this.serverSidePlayQueue = false,
|
||||
this.serverSidePlaylists = false,
|
||||
this.liveTv = false,
|
||||
this.liveTvDvr = false,
|
||||
this.subtitleSearch = false,
|
||||
this.videoTranscoding = true,
|
||||
this.serverSideSync = false,
|
||||
this.richHubs = false,
|
||||
this.numericUserRating = false,
|
||||
this.externalSubtitleSearch = false,
|
||||
this.trackPreferencePersistence = false,
|
||||
this.endpointFailover = false,
|
||||
this.offlineWatchQueue = false,
|
||||
this.discordRpc = false,
|
||||
this.richMetadataEdit = false,
|
||||
this.alphaBar = AlphaBarMode.none,
|
||||
this.scrubThumbnails = false,
|
||||
this.folderGrouping = false,
|
||||
});
|
||||
|
||||
/// Defaults for a fully-featured Plex server.
|
||||
static const ServerCapabilities plex = ServerCapabilities(
|
||||
serverSidePlayQueue: true,
|
||||
serverSidePlaylists: true,
|
||||
liveTv: true,
|
||||
liveTvDvr: true,
|
||||
subtitleSearch: true,
|
||||
videoTranscoding: true,
|
||||
serverSideSync: true,
|
||||
richHubs: true,
|
||||
numericUserRating: true,
|
||||
externalSubtitleSearch: true,
|
||||
trackPreferencePersistence: true,
|
||||
endpointFailover: true,
|
||||
offlineWatchQueue: true,
|
||||
discordRpc: true,
|
||||
richMetadataEdit: true,
|
||||
alphaBar: AlphaBarMode.scrollSnap,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
);
|
||||
|
||||
/// Defaults for a Jellyfin server.
|
||||
///
|
||||
/// `videoTranscoding` is `true` — `JellyfinClient.getPlaybackInitialization`
|
||||
/// negotiates via `POST /Items/{id}/PlaybackInfo` and uses the server's
|
||||
/// `TranscodingUrl` when a non-original quality preset is selected.
|
||||
///
|
||||
/// `liveTv` is `true` because Jellyfin exposes `/LiveTv/Channels` and
|
||||
/// `/LiveTv/Programs`. Detection + channel listing are wired today;
|
||||
/// EPG and tuning are follow-ups.
|
||||
static const ServerCapabilities jellyfin = ServerCapabilities(
|
||||
serverSidePlayQueue: false,
|
||||
serverSidePlaylists: true,
|
||||
liveTv: true,
|
||||
liveTvDvr: false,
|
||||
subtitleSearch: false,
|
||||
videoTranscoding: true,
|
||||
serverSideSync: false,
|
||||
richHubs: false,
|
||||
numericUserRating: false,
|
||||
externalSubtitleSearch: false,
|
||||
trackPreferencePersistence: true,
|
||||
endpointFailover: false,
|
||||
offlineWatchQueue: false,
|
||||
discordRpc: false,
|
||||
richMetadataEdit: false,
|
||||
alphaBar: AlphaBarMode.nameStartsWithFilter,
|
||||
scrubThumbnails: true,
|
||||
);
|
||||
|
||||
ServerCapabilities copyWith({
|
||||
bool? serverSidePlayQueue,
|
||||
bool? serverSidePlaylists,
|
||||
bool? liveTv,
|
||||
bool? liveTvDvr,
|
||||
bool? subtitleSearch,
|
||||
bool? videoTranscoding,
|
||||
bool? serverSideSync,
|
||||
bool? richHubs,
|
||||
bool? numericUserRating,
|
||||
bool? externalSubtitleSearch,
|
||||
bool? trackPreferencePersistence,
|
||||
bool? endpointFailover,
|
||||
bool? offlineWatchQueue,
|
||||
bool? discordRpc,
|
||||
bool? richMetadataEdit,
|
||||
AlphaBarMode? alphaBar,
|
||||
bool? scrubThumbnails,
|
||||
bool? folderGrouping,
|
||||
}) {
|
||||
return ServerCapabilities(
|
||||
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
|
||||
serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists,
|
||||
liveTv: liveTv ?? this.liveTv,
|
||||
liveTvDvr: liveTvDvr ?? this.liveTvDvr,
|
||||
subtitleSearch: subtitleSearch ?? this.subtitleSearch,
|
||||
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
||||
serverSideSync: serverSideSync ?? this.serverSideSync,
|
||||
richHubs: richHubs ?? this.richHubs,
|
||||
numericUserRating: numericUserRating ?? this.numericUserRating,
|
||||
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch,
|
||||
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence,
|
||||
endpointFailover: endpointFailover ?? this.endpointFailover,
|
||||
offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue,
|
||||
discordRpc: discordRpc ?? this.discordRpc,
|
||||
richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit,
|
||||
alphaBar: alphaBar ?? this.alphaBar,
|
||||
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
|
||||
folderGrouping: folderGrouping ?? this.folderGrouping,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,16 @@ import 'event_aware.dart';
|
||||
/// Example usage:
|
||||
/// ```dart
|
||||
/// class _MyScreenState extends State<MyScreen> with DeletionAware {
|
||||
/// List<PlexMetadata> _items = [];
|
||||
/// List<MediaItem> _items = [];
|
||||
///
|
||||
/// @override
|
||||
/// Set<String>? get deletionRatingKeys =>
|
||||
/// _items.map((e) => e.ratingKey).toSet();
|
||||
/// Set<String>? get deletionIds =>
|
||||
/// _items.map((e) => e.id).toSet();
|
||||
///
|
||||
/// @override
|
||||
/// void onDeletionEvent(DeletionEvent event) {
|
||||
/// setState(() {
|
||||
/// _items.removeWhere((e) => e.ratingKey == event.ratingKey);
|
||||
/// _items.removeWhere((e) => e.id == event.itemId);
|
||||
/// });
|
||||
/// }
|
||||
/// }
|
||||
@@ -36,22 +36,22 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
/// Override to specify which global keys this screen cares about.
|
||||
///
|
||||
/// Use format `serverId:ratingKey`.
|
||||
/// Return null to fall back to [deletionRatingKeys] matching.
|
||||
/// Return null to fall back to [deletionIds] matching.
|
||||
Set<String>? get deletionGlobalKeys => null;
|
||||
|
||||
/// Override to specify which ratingKeys this screen cares about.
|
||||
/// Override to specify which item ids this screen cares about.
|
||||
///
|
||||
/// Return null to receive ALL events (not recommended for performance).
|
||||
/// Return an empty set to receive no events.
|
||||
///
|
||||
/// The set should include:
|
||||
/// - Direct items displayed (e.g., episode ratingKeys in a season view)
|
||||
/// - Parent items that affect display (e.g., show ratingKey for seasons)
|
||||
Set<String>? get deletionRatingKeys;
|
||||
/// - Direct items displayed (e.g., episode ids in a season view)
|
||||
/// - Parent items that affect display (e.g., show id for seasons)
|
||||
Set<String>? get deletionIds;
|
||||
|
||||
/// Called when a relevant deletion event occurs.
|
||||
///
|
||||
/// Only called if [deletionRatingKeys] is null or contains an affected key.
|
||||
/// Only called if [deletionIds] is null or contains an affected key.
|
||||
void onDeletionEvent(DeletionEvent event);
|
||||
|
||||
@override
|
||||
@@ -62,7 +62,7 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
mounted: () => mounted,
|
||||
serverId: () => deletionServerId,
|
||||
globalKeys: () => deletionGlobalKeys,
|
||||
ratingKeys: () => deletionRatingKeys,
|
||||
itemIds: () => deletionIds,
|
||||
onEvent: onDeletionEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventM
|
||||
required bool Function() mounted,
|
||||
required String? Function() serverId,
|
||||
required Set<String>? Function() globalKeys,
|
||||
required Set<String>? Function() ratingKeys,
|
||||
required Set<String>? Function() itemIds,
|
||||
required void Function(E event) onEvent,
|
||||
}) {
|
||||
return notifier.stream.listen((event) {
|
||||
@@ -28,8 +28,8 @@ StreamSubscription<E> subscribeToHierarchicalEvents<E extends HierarchicalEventM
|
||||
return;
|
||||
}
|
||||
|
||||
final rk = ratingKeys();
|
||||
if (rk == null || event.affectsAnyOf(rk)) {
|
||||
final ids = itemIds();
|
||||
if (ids == null || event.affectsAnyOf(ids)) {
|
||||
onEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Mixin for screens that need to update individual items after watch state changes
|
||||
///
|
||||
@@ -8,9 +8,10 @@ import '../models/plex_metadata.dart';
|
||||
/// and replacing items in lists, while allowing each screen to customize
|
||||
/// which lists should be updated.
|
||||
mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
/// The Plex client to use for fetching updated metadata
|
||||
/// Each screen must provide access to their client
|
||||
PlexClient get client;
|
||||
/// Override to enable backend-aware item refresh. [updateItem] resolves
|
||||
/// the right [MediaServerClient] for the item's server. When null,
|
||||
/// [updateItem] is a no-op.
|
||||
String? get itemServerId => null;
|
||||
|
||||
/// Updates a single item in the screen's list(s) after watch state changes
|
||||
///
|
||||
@@ -19,12 +20,14 @@ mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// If the fetch fails, the error is silently caught and the item will
|
||||
/// be updated on the next full refresh.
|
||||
Future<void> updateItem(String ratingKey) async {
|
||||
Future<void> updateItem(String itemId) async {
|
||||
try {
|
||||
final updatedMetadata = await client.getMetadataWithImages(ratingKey);
|
||||
if (updatedMetadata != null) {
|
||||
final serverId = itemServerId;
|
||||
if (serverId == null) return;
|
||||
final updatedItem = await context.tryGetMediaClientForServer(serverId)?.fetchItem(itemId);
|
||||
if (updatedItem != null) {
|
||||
setState(() {
|
||||
updateItemInLists(ratingKey, updatedMetadata);
|
||||
updateItemInLists(itemId, updatedItem);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -40,12 +43,12 @@ mixin ItemUpdatable<T extends StatefulWidget> on State<T> {
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// @override
|
||||
/// void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||
/// final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||
/// void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
/// final index = _items.indexWhere((item) => item.id == itemId);
|
||||
/// if (index != -1) {
|
||||
/// _items[index] = updatedMetadata;
|
||||
/// _items[index] = updatedItem;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata);
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../media/media_library.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../models/plex_library.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Mixin providing common functionality for library tab screens
|
||||
/// Provides server-specific client resolution for multi-server support
|
||||
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// The library being displayed
|
||||
PlexLibrary get library;
|
||||
MediaLibrary get library;
|
||||
|
||||
/// Get the correct PlexClient for this library's server
|
||||
/// Throws an exception if no client is available
|
||||
PlexClient getClientForLibrary() => context.getClientForLibrary(library);
|
||||
/// Get the [PlexClient] for this library's server. Throws if unavailable.
|
||||
/// Use [getMediaClientForLibrary] in code paths that work for both Plex
|
||||
/// and Jellyfin via the [MediaServerClient] interface — this getter is
|
||||
/// for Plex-only methods (collections, metadata edit, etc.).
|
||||
PlexClient getClientForLibrary() => context.getPlexClientForLibrary(library);
|
||||
|
||||
/// Get a backend-neutral [MediaServerClient] for this library's server.
|
||||
/// Throws if unavailable. Prefer this over [getClientForLibrary] for any
|
||||
/// flow that doesn't strictly need Plex-only APIs.
|
||||
MediaServerClient getMediaClientForLibrary() => context.getMediaClientForLibrary(library);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/plex_http_client.dart';
|
||||
import '../utils/plex_http_exception.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
|
||||
/// Sparse-loading state + fetch orchestration for paginated item grids/lists.
|
||||
///
|
||||
@@ -22,7 +22,7 @@ import '../utils/plex_http_exception.dart';
|
||||
/// 3. On dispose, subclass calls [disposePagination].
|
||||
mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// Sparse map of loaded items, keyed by position.
|
||||
final Map<int, PlexMetadata> loadedItems = {};
|
||||
final Map<int, MediaItem> loadedItems = {};
|
||||
|
||||
/// Total items on the server. 0 until the first page completes.
|
||||
int totalSize = 0;
|
||||
@@ -43,12 +43,12 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
VoidCallback? _scheduledRetry;
|
||||
|
||||
/// Fetch a page of items. Subclass implements this — typically delegating
|
||||
/// to a paginated `PlexClient` method that returns a [LibraryContentResult].
|
||||
Future<LibraryContentResult> fetchPage(int start, int size, AbortController? abort);
|
||||
/// to a paginated client method that returns a [LibraryPage] of [MediaItem].
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort);
|
||||
|
||||
/// Hook fired after each successful page merge. Default: no-op.
|
||||
/// Override for image prefetch, syncing a base-class `items` list, etc.
|
||||
void onPageLoaded(int start, List<PlexMetadata> items) {}
|
||||
void onPageLoaded(int start, List<MediaItem> items) {}
|
||||
|
||||
/// Synchronously clear pagination state and bump the generation counter.
|
||||
/// Call from inside the subclass's `setState` before awaiting
|
||||
@@ -69,7 +69,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
|
||||
/// Fetch the first page. Await from outside `setState`. Mutates
|
||||
/// [loadedItems] and [totalSize] on success; throws on failure.
|
||||
Future<LibraryContentResult> loadInitialPage(int pageSize) async {
|
||||
Future<LibraryPage<MediaItem>> loadInitialPage(int pageSize) async {
|
||||
final generation = _requestId;
|
||||
final result = await fetchPage(0, pageSize, _cancelToken);
|
||||
if (generation != _requestId || !mounted) return result;
|
||||
@@ -77,7 +77,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[i] = result.items[i];
|
||||
}
|
||||
totalSize = result.totalSize;
|
||||
totalSize = result.totalCount;
|
||||
onPageLoaded(0, result.items);
|
||||
return result;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
/// [totalSize] even if [index] wasn't in the sparse map (evicted).
|
||||
void removeLoadedItemAndShift(int index) {
|
||||
loadedItems.remove(index);
|
||||
final shifted = <int, PlexMetadata>{};
|
||||
final shifted = <int, MediaItem>{};
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.key > index) {
|
||||
shifted[entry.key - 1] = entry.value;
|
||||
@@ -229,14 +229,14 @@ mixin PaginatedItemLoader<W extends StatefulWidget> on State<W> {
|
||||
for (var i = 0; i < result.items.length; i++) {
|
||||
loadedItems[start + i] = result.items[i];
|
||||
}
|
||||
if (result.totalSize != totalSize) totalSize = result.totalSize;
|
||||
if (result.totalCount != totalSize) totalSize = result.totalCount;
|
||||
});
|
||||
|
||||
_retryCount = 0;
|
||||
onPageLoaded(start, result.items);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false;
|
||||
if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false;
|
||||
_retryCount++;
|
||||
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
|
||||
_retryTimer?.cancel();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
|
||||
/// Shared helpers for screens bound to a single [PlexMetadata] item/server.
|
||||
/// Shared helpers for screens bound to a single [MediaItem]/server.
|
||||
mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
|
||||
PlexMetadata get serverBoundMetadata;
|
||||
MediaItem get serverBoundMetadata;
|
||||
|
||||
bool get isServerBoundOffline => false;
|
||||
|
||||
@@ -16,6 +17,16 @@ mixin ServerBoundMediaMixin<T extends StatefulWidget> on State<T> {
|
||||
String toServerBoundGlobalKey(String ratingKey, {String? serverId}) =>
|
||||
buildGlobalKey(serverId ?? serverBoundServerId ?? '', ratingKey);
|
||||
|
||||
PlexClient? getServerBoundClient(BuildContext context) =>
|
||||
context.getClientForMetadataOrNull(serverBoundMetadata, isOffline: isServerBoundOffline);
|
||||
/// Returns the [PlexClient] for the bound server, or null when offline /
|
||||
/// the server is Jellyfin / not registered. Use [getServerBoundMediaClient]
|
||||
/// for backend-neutral flows.
|
||||
PlexClient? getServerBoundPlexClient(BuildContext context) {
|
||||
if (isServerBoundOffline) return null;
|
||||
return context.tryGetPlexClientForServer(serverBoundMetadata.serverId);
|
||||
}
|
||||
|
||||
/// Returns a backend-neutral [MediaServerClient] for the bound server, or
|
||||
/// null when offline / not registered.
|
||||
MediaServerClient? getServerBoundMediaClient(BuildContext context) =>
|
||||
context.getMediaClientForItemOrNull(serverBoundMetadata, isOffline: isServerBoundOffline);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import '../widgets/focusable_tab_chip.dart';
|
||||
///
|
||||
/// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab.
|
||||
mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderStateMixin<T> {
|
||||
late final TabController tabController;
|
||||
/// Mutable so [initTabNavigation] can be called more than once during a
|
||||
/// single State lifetime — the libraries screen rebuilds the controller
|
||||
/// when the visible tab set changes (Jellyfin shows Browse only;
|
||||
/// switching back to a Plex library goes from 1 tab to 4).
|
||||
late TabController tabController;
|
||||
|
||||
/// When true, suppress auto-focus in tabs (used when navigating via tab bar).
|
||||
bool suppressAutoFocus = false;
|
||||
|
||||
@@ -11,16 +11,16 @@ import 'event_aware.dart';
|
||||
/// Example usage:
|
||||
/// ```dart
|
||||
/// class _MyScreenState extends State<MyScreen> with WatchStateAware {
|
||||
/// List<PlexMetadata> _items = [];
|
||||
/// List<MediaItem> _items = [];
|
||||
///
|
||||
/// @override
|
||||
/// Set<String>? get watchedRatingKeys =>
|
||||
/// _items.map((e) => e.ratingKey).toSet();
|
||||
/// Set<String>? get watchedIds =>
|
||||
/// _items.map((e) => e.id).toSet();
|
||||
///
|
||||
/// @override
|
||||
/// void onWatchStateChanged(WatchStateEvent event) {
|
||||
/// // Refresh affected item
|
||||
/// _refreshItem(event.ratingKey);
|
||||
/// _refreshItem(event.itemId);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@@ -35,22 +35,22 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
|
||||
/// Override to specify which global keys this screen cares about.
|
||||
///
|
||||
/// Use format `serverId:ratingKey`.
|
||||
/// Return null to fall back to [watchedRatingKeys] matching.
|
||||
/// Return null to fall back to [watchedIds] matching.
|
||||
Set<String>? get watchedGlobalKeys => null;
|
||||
|
||||
/// Override to specify which ratingKeys this screen cares about.
|
||||
/// Override to specify which item ids this screen cares about.
|
||||
///
|
||||
/// Return null to receive ALL events (not recommended for performance).
|
||||
/// Return an empty set to receive no events.
|
||||
///
|
||||
/// The set should include:
|
||||
/// - Direct items displayed (e.g., episode ratingKeys in a season view)
|
||||
/// - Parent items that affect display (e.g., show ratingKey for on-deck)
|
||||
Set<String>? get watchedRatingKeys;
|
||||
/// - Direct items displayed (e.g., episode ids in a season view)
|
||||
/// - Parent items that affect display (e.g., show id for on-deck)
|
||||
Set<String>? get watchedIds;
|
||||
|
||||
/// Called when a relevant watch state change occurs.
|
||||
///
|
||||
/// Only called if [watchedRatingKeys] is null or contains an affected key.
|
||||
/// Only called if [watchedIds] is null or contains an affected key.
|
||||
void onWatchStateChanged(WatchStateEvent event);
|
||||
|
||||
@override
|
||||
@@ -61,7 +61,7 @@ mixin WatchStateAware<T extends StatefulWidget> on State<T> {
|
||||
mounted: () => mounted,
|
||||
serverId: () => watchStateServerId,
|
||||
globalKeys: () => watchedGlobalKeys,
|
||||
ratingKeys: () => watchedRatingKeys,
|
||||
itemIds: () => watchedIds,
|
||||
onEvent: onWatchStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import '../../media/media_server_user_profile.dart';
|
||||
|
||||
/// Jellyfin user playback preferences, sourced from `User.Configuration`
|
||||
/// (returned by `/Users/{userId}` or `/Users/Me`). Jellyfin exposes only a
|
||||
/// single ranked audio/subtitle language, so the multi-list accessors on
|
||||
/// [MediaServerUserProfile] return null.
|
||||
class JellyfinUserProfile implements MediaServerUserProfile {
|
||||
@override
|
||||
final bool autoSelectAudio;
|
||||
|
||||
@override
|
||||
final String? defaultAudioLanguage;
|
||||
|
||||
@override
|
||||
final String? defaultSubtitleLanguage;
|
||||
|
||||
/// Server-reported subtitle mode (None / Default / Always / OnlyForced /
|
||||
/// Smart).
|
||||
@override
|
||||
final SubtitlePlaybackMode? subtitleMode;
|
||||
|
||||
const JellyfinUserProfile({
|
||||
required this.autoSelectAudio,
|
||||
this.defaultAudioLanguage,
|
||||
this.defaultSubtitleLanguage,
|
||||
this.subtitleMode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<String>? get defaultAudioLanguages => null;
|
||||
|
||||
@override
|
||||
List<String>? get defaultSubtitleLanguages => null;
|
||||
|
||||
/// Build from `/Users/Me` (or `/Users/{userId}`) response — pulls the
|
||||
/// `Configuration` block; missing values fall back to server defaults
|
||||
/// (auto-select on, no language preference).
|
||||
factory JellyfinUserProfile.fromUserDto(Map<String, dynamic> json) {
|
||||
final config = json['Configuration'] as Map<String, dynamic>? ?? const {};
|
||||
final audio = config['AudioLanguagePreference'] as String?;
|
||||
final subtitle = config['SubtitleLanguagePreference'] as String?;
|
||||
final playDefault = config['PlayDefaultAudioTrack'] as bool? ?? true;
|
||||
return JellyfinUserProfile(
|
||||
autoSelectAudio: playDefault,
|
||||
defaultAudioLanguage: (audio == null || audio.isEmpty) ? null : audio,
|
||||
defaultSubtitleLanguage: (subtitle == null || subtitle.isEmpty) ? null : subtitle,
|
||||
subtitleMode: SubtitlePlaybackMode.fromServerValue(config['SubtitleMode']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ Object? _readChannelNumber(Map json, String _) =>
|
||||
|
||||
Object? _readFavoriteChannelId(Map json, String _) => json['id'] as String? ?? json['key'] as String? ?? '';
|
||||
|
||||
String favoriteChannelKey(String source, String id) => '$source\u0000$id';
|
||||
|
||||
String liveTvChannelScopeKey(LiveTvChannel channel) =>
|
||||
'${channel.serverId ?? ''}\u0000${channel.liveDvrKey ?? ''}\u0000${channel.key}';
|
||||
|
||||
/// Represents a Live TV channel from the EPG
|
||||
@JsonSerializable(createToJson: false)
|
||||
class LiveTvChannel with MultiServerFields {
|
||||
@@ -53,6 +58,12 @@ class LiveTvChannel with MultiServerFields {
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? liveDvrKey;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? favoriteSource;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? favoriteStoreKey;
|
||||
|
||||
LiveTvChannel({
|
||||
required this.key,
|
||||
@@ -68,11 +79,20 @@ class LiveTvChannel with MultiServerFields {
|
||||
this.drm,
|
||||
this.serverId,
|
||||
this.serverName,
|
||||
this.liveDvrKey,
|
||||
this.favoriteSource,
|
||||
this.favoriteStoreKey,
|
||||
});
|
||||
|
||||
factory LiveTvChannel.fromJson(Map<String, dynamic> json) => _$LiveTvChannelFromJson(json);
|
||||
|
||||
LiveTvChannel copyWith({String? serverId, String? serverName}) {
|
||||
LiveTvChannel copyWith({
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
String? liveDvrKey,
|
||||
String? favoriteSource,
|
||||
String? favoriteStoreKey,
|
||||
}) {
|
||||
return LiveTvChannel(
|
||||
key: key,
|
||||
identifier: identifier,
|
||||
@@ -87,6 +107,9 @@ class LiveTvChannel with MultiServerFields {
|
||||
drm: drm,
|
||||
serverId: serverId ?? this.serverId,
|
||||
serverName: serverName ?? this.serverName,
|
||||
liveDvrKey: liveDvrKey ?? this.liveDvrKey,
|
||||
favoriteSource: favoriteSource ?? this.favoriteSource,
|
||||
favoriteStoreKey: favoriteStoreKey ?? this.favoriteStoreKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +133,8 @@ class FavoriteChannel {
|
||||
|
||||
factory FavoriteChannel.fromJson(Map<String, dynamic> json) => _$FavoriteChannelFromJson(json);
|
||||
|
||||
String get stableKey => favoriteChannelKey(source, id);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'source': source,
|
||||
'id': id,
|
||||
|
||||
@@ -6,26 +6,24 @@ part of 'livetv_channel.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
LiveTvChannel _$LiveTvChannelFromJson(Map<String, dynamic> json) =>
|
||||
LiveTvChannel(
|
||||
key: _readChannelKey(json, 'key') as String,
|
||||
identifier: _readChannelIdentifier(json, 'identifier') as String?,
|
||||
callSign: json['callSign'] as String?,
|
||||
title: _readChannelTitle(json, 'title') as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
number: _readChannelNumber(json, 'number') as String?,
|
||||
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
|
||||
lineup: json['lineup'] as String?,
|
||||
slug: json['slug'] as String?,
|
||||
drm: flexibleBool(json['drm']),
|
||||
);
|
||||
LiveTvChannel _$LiveTvChannelFromJson(Map<String, dynamic> json) => LiveTvChannel(
|
||||
key: _readChannelKey(json, 'key') as String,
|
||||
identifier: _readChannelIdentifier(json, 'identifier') as String?,
|
||||
callSign: json['callSign'] as String?,
|
||||
title: _readChannelTitle(json, 'title') as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
art: json['art'] as String?,
|
||||
number: _readChannelNumber(json, 'number') as String?,
|
||||
hd: json['hd'] == null ? false : flexibleBool(json['hd']),
|
||||
lineup: json['lineup'] as String?,
|
||||
slug: json['slug'] as String?,
|
||||
drm: flexibleBool(json['drm']),
|
||||
);
|
||||
|
||||
FavoriteChannel _$FavoriteChannelFromJson(Map<String, dynamic> json) =>
|
||||
FavoriteChannel(
|
||||
source: json['source'] as String? ?? '',
|
||||
id: _readFavoriteChannelId(json, 'id') as String,
|
||||
title: json['title'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
vcn: json['vcn'] as String?,
|
||||
);
|
||||
FavoriteChannel _$FavoriteChannelFromJson(Map<String, dynamic> json) => FavoriteChannel(
|
||||
source: json['source'] as String? ?? '',
|
||||
id: _readFavoriteChannelId(json, 'id') as String,
|
||||
title: json['title'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
vcn: json['vcn'] as String?,
|
||||
);
|
||||
|
||||
@@ -20,15 +20,12 @@ LiveTvDvr _$LiveTvDvrFromJson(Map<String, dynamic> json) => LiveTvDvr(
|
||||
country: json['country'] as String?,
|
||||
language: json['language'] as String?,
|
||||
status: json['status'] as String?,
|
||||
channelMappings: json['ChannelMapping'] == null
|
||||
? const []
|
||||
: _parseChannelMappings(json['ChannelMapping']),
|
||||
channelMappings: json['ChannelMapping'] == null ? const [] : _parseChannelMappings(json['ChannelMapping']),
|
||||
);
|
||||
|
||||
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) =>
|
||||
ChannelMapping(
|
||||
channelKey: json['channelKey'] as String?,
|
||||
deviceIdentifier: json['deviceIdentifier'] as String?,
|
||||
enabled: flexibleBool(json['enabled']),
|
||||
lineupIdentifier: json['lineupIdentifier'] as String?,
|
||||
);
|
||||
ChannelMapping _$ChannelMappingFromJson(Map<String, dynamic> json) => ChannelMapping(
|
||||
channelKey: json['channelKey'] as String?,
|
||||
deviceIdentifier: json['deviceIdentifier'] as String?,
|
||||
enabled: flexibleBool(json['enabled']),
|
||||
lineupIdentifier: json['lineupIdentifier'] as String?,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'plex_metadata.dart';
|
||||
import '../media/media_item.dart';
|
||||
import 'livetv_program.dart';
|
||||
|
||||
/// A hub from the live TV discover endpoint, with both display and EPG data.
|
||||
@@ -12,7 +12,7 @@ class LiveTvHubResult {
|
||||
|
||||
/// A single item in a live TV hub, holding both display metadata and EPG timing.
|
||||
class LiveTvHubEntry {
|
||||
final PlexMetadata metadata;
|
||||
final MediaItem metadata;
|
||||
final LiveTvProgram program;
|
||||
|
||||
LiveTvHubEntry({required this.metadata, required this.program});
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'plex_metadata.dart';
|
||||
|
||||
part 'play_queue_response.g.dart';
|
||||
|
||||
/// Response from Plex play queue API
|
||||
/// Contains queue metadata and a window of items
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlayQueueResponse {
|
||||
final int playQueueID;
|
||||
final int? playQueueSelectedItemID;
|
||||
final int? playQueueSelectedItemOffset;
|
||||
final String? playQueueSelectedMetadataItemID;
|
||||
@JsonKey(fromJson: flexibleBool)
|
||||
final bool playQueueShuffled;
|
||||
final String? playQueueSourceURI;
|
||||
final int? playQueueTotalCount;
|
||||
final int playQueueVersion;
|
||||
final int? size; // Number of items in this response window
|
||||
@JsonKey(name: 'Metadata')
|
||||
final List<PlexMetadata>? items;
|
||||
|
||||
PlayQueueResponse({
|
||||
required this.playQueueID,
|
||||
this.playQueueSelectedItemID,
|
||||
this.playQueueSelectedItemOffset,
|
||||
this.playQueueSelectedMetadataItemID,
|
||||
required this.playQueueShuffled,
|
||||
this.playQueueSourceURI,
|
||||
required this.playQueueTotalCount,
|
||||
required this.playQueueVersion,
|
||||
this.size,
|
||||
this.items,
|
||||
});
|
||||
|
||||
factory PlayQueueResponse.fromJson(Map<String, dynamic> json, {String? serverId, String? serverName}) {
|
||||
// The API returns data wrapped in MediaContainer
|
||||
final container = json['MediaContainer'] as Map<String, dynamic>? ?? json;
|
||||
final response = _$PlayQueueResponseFromJson(container);
|
||||
|
||||
// Tag all items with server info
|
||||
if (response.items != null && (serverId != null || serverName != null)) {
|
||||
final taggedItems = response.items!
|
||||
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
|
||||
.toList();
|
||||
return PlayQueueResponse(
|
||||
playQueueID: response.playQueueID,
|
||||
playQueueSelectedItemID: response.playQueueSelectedItemID,
|
||||
playQueueSelectedItemOffset: response.playQueueSelectedItemOffset,
|
||||
playQueueSelectedMetadataItemID: response.playQueueSelectedMetadataItemID,
|
||||
playQueueShuffled: response.playQueueShuffled,
|
||||
playQueueSourceURI: response.playQueueSourceURI,
|
||||
playQueueTotalCount: response.playQueueTotalCount,
|
||||
playQueueVersion: response.playQueueVersion,
|
||||
size: response.size,
|
||||
items: taggedItems,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Get the current selected item from the queue
|
||||
PlexMetadata? get selectedItem {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
try {
|
||||
return items!.firstWhere((item) => item.playQueueItemID == playQueueSelectedItemID);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the index of the selected item in the current window
|
||||
int? get selectedItemIndex {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
return items!.indexWhere((item) => item.playQueueItemID == playQueueSelectedItemID);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'play_queue_response.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
||||
PlayQueueResponse(
|
||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
||||
?.toInt(),
|
||||
playQueueSelectedMetadataItemID:
|
||||
json['playQueueSelectedMetadataItemID'] as String?,
|
||||
playQueueShuffled: flexibleBool(json['playQueueShuffled']),
|
||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||
size: (json['size'] as num?)?.toInt(),
|
||||
items: (json['Metadata'] as List<dynamic>?)
|
||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../../media/media_item.dart';
|
||||
|
||||
/// Response from Plex play queue API.
|
||||
/// Contains queue metadata and a window of items in neutral [MediaItem] form.
|
||||
class PlayQueueResponse {
|
||||
final int playQueueID;
|
||||
final int? playQueueSelectedItemID;
|
||||
final int? playQueueSelectedItemOffset;
|
||||
final String? playQueueSelectedMetadataItemID;
|
||||
final bool playQueueShuffled;
|
||||
final String? playQueueSourceURI;
|
||||
final int? playQueueTotalCount;
|
||||
final int playQueueVersion;
|
||||
final int? size; // Number of items in this response window
|
||||
final List<MediaItem>? items;
|
||||
|
||||
PlayQueueResponse({
|
||||
required this.playQueueID,
|
||||
this.playQueueSelectedItemID,
|
||||
this.playQueueSelectedItemOffset,
|
||||
this.playQueueSelectedMetadataItemID,
|
||||
required this.playQueueShuffled,
|
||||
this.playQueueSourceURI,
|
||||
required this.playQueueTotalCount,
|
||||
required this.playQueueVersion,
|
||||
this.size,
|
||||
this.items,
|
||||
});
|
||||
|
||||
/// Get the current selected item from the queue. Items in a Plex
|
||||
/// `PlayQueueResponse` are always [PlexMediaItem]; the cast is safe.
|
||||
MediaItem? get selectedItem {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
try {
|
||||
return items!.firstWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the index of the selected item in the current window
|
||||
int? get selectedItemIndex {
|
||||
if (items == null || playQueueSelectedItemID == null) return null;
|
||||
return items!.indexWhere((item) => item is PlexMediaItem && item.playQueueItemId == playQueueSelectedItemID);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../utils/json_utils.dart';
|
||||
import '../../utils/json_utils.dart';
|
||||
|
||||
part 'plex_match_result.g.dart';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_match_result.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexMatchResult _$PlexMatchResultFromJson(Map<String, dynamic> json) => PlexMatchResult(
|
||||
guid: readStringField(json, 'guid') as String? ?? '',
|
||||
name: readStringField(json, 'name') as String? ?? '',
|
||||
year: flexibleInt(json['year']),
|
||||
score: flexibleInt(json['score']),
|
||||
thumb: readStringField(json, 'thumb') as String?,
|
||||
summary: readStringField(json, 'summary') as String?,
|
||||
type: readStringField(json, 'type') as String?,
|
||||
matched: json['matched'] == null ? false : flexibleBool(json['matched']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexMatchResultToJson(PlexMatchResult instance) => <String, dynamic>{
|
||||
'guid': instance.guid,
|
||||
'name': instance.name,
|
||||
'year': instance.year,
|
||||
'score': instance.score,
|
||||
'thumb': instance.thumb,
|
||||
'summary': instance.summary,
|
||||
'type': instance.type,
|
||||
'matched': instance.matched,
|
||||
};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../utils/json_utils.dart';
|
||||
import '../../utils/json_utils.dart';
|
||||
|
||||
part 'plex_subtitle_search_result.g.dart';
|
||||
|
||||
+5
-15
@@ -6,9 +6,7 @@ part of 'plex_subtitle_search_result.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => PlexSubtitleSearchResult(
|
||||
PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(Map<String, dynamic> json) => PlexSubtitleSearchResult(
|
||||
id: _flexibleIntOrZero(json['id']),
|
||||
key: readStringField(json, 'key') as String? ?? '',
|
||||
codec: readStringField(json, 'codec') as String?,
|
||||
@@ -18,21 +16,13 @@ PlexSubtitleSearchResult _$PlexSubtitleSearchResultFromJson(
|
||||
providerTitle: readStringField(json, 'providerTitle') as String?,
|
||||
title: readStringField(json, 'title') as String?,
|
||||
displayTitle: readStringField(json, 'displayTitle') as String?,
|
||||
hearingImpaired: json['hearingImpaired'] == null
|
||||
? false
|
||||
: flexibleBool(json['hearingImpaired']),
|
||||
perfectMatch: json['perfectMatch'] == null
|
||||
? false
|
||||
: flexibleBool(json['perfectMatch']),
|
||||
downloaded: json['downloaded'] == null
|
||||
? false
|
||||
: flexibleBool(json['downloaded']),
|
||||
hearingImpaired: json['hearingImpaired'] == null ? false : flexibleBool(json['hearingImpaired']),
|
||||
perfectMatch: json['perfectMatch'] == null ? false : flexibleBool(json['perfectMatch']),
|
||||
downloaded: json['downloaded'] == null ? false : flexibleBool(json['downloaded']),
|
||||
forced: json['forced'] == null ? false : flexibleBool(json['forced']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexSubtitleSearchResultToJson(
|
||||
PlexSubtitleSearchResult instance,
|
||||
) => <String, dynamic>{
|
||||
Map<String, dynamic> _$PlexSubtitleSearchResultToJson(PlexSubtitleSearchResult instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'key': instance.key,
|
||||
'codec': instance.codec,
|
||||
@@ -1,18 +1,25 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../../media/media_server_user_profile.dart';
|
||||
|
||||
part 'plex_user_profile.g.dart';
|
||||
|
||||
/// Represents a Plex user's profile preferences
|
||||
/// Fetched from https://clients.plex.tv/api/v2/user
|
||||
@JsonSerializable()
|
||||
class PlexUserProfile {
|
||||
class PlexUserProfile implements MediaServerUserProfile {
|
||||
@JsonKey(defaultValue: true)
|
||||
@override
|
||||
final bool autoSelectAudio;
|
||||
@JsonKey(defaultValue: 0)
|
||||
final int defaultAudioAccessibility;
|
||||
@override
|
||||
final String? defaultAudioLanguage;
|
||||
@override
|
||||
final List<String>? defaultAudioLanguages;
|
||||
@override
|
||||
final String? defaultSubtitleLanguage;
|
||||
@override
|
||||
final List<String>? defaultSubtitleLanguages;
|
||||
@JsonKey(defaultValue: 0)
|
||||
final int autoSelectSubtitle;
|
||||
@@ -26,6 +33,9 @@ class PlexUserProfile {
|
||||
final int mediaReviewsVisibility;
|
||||
final List<String>? mediaReviewsLanguages;
|
||||
|
||||
@override
|
||||
SubtitlePlaybackMode? get subtitleMode => null;
|
||||
|
||||
PlexUserProfile({
|
||||
required this.autoSelectAudio,
|
||||
required this.defaultAudioAccessibility,
|
||||
@@ -0,0 +1,37 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_user_profile.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexUserProfile _$PlexUserProfileFromJson(Map<String, dynamic> json) => PlexUserProfile(
|
||||
autoSelectAudio: json['autoSelectAudio'] as bool? ?? true,
|
||||
defaultAudioAccessibility: (json['defaultAudioAccessibility'] as num?)?.toInt() ?? 0,
|
||||
defaultAudioLanguage: json['defaultAudioLanguage'] as String?,
|
||||
defaultAudioLanguages: (json['defaultAudioLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
defaultSubtitleLanguage: json['defaultSubtitleLanguage'] as String?,
|
||||
defaultSubtitleLanguages: (json['defaultSubtitleLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
autoSelectSubtitle: (json['autoSelectSubtitle'] as num?)?.toInt() ?? 0,
|
||||
defaultSubtitleAccessibility: (json['defaultSubtitleAccessibility'] as num?)?.toInt() ?? 0,
|
||||
defaultSubtitleForced: (json['defaultSubtitleForced'] as num?)?.toInt() ?? 1,
|
||||
watchedIndicator: (json['watchedIndicator'] as num?)?.toInt() ?? 1,
|
||||
mediaReviewsVisibility: (json['mediaReviewsVisibility'] as num?)?.toInt() ?? 0,
|
||||
mediaReviewsLanguages: (json['mediaReviewsLanguages'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexUserProfileToJson(PlexUserProfile instance) => <String, dynamic>{
|
||||
'autoSelectAudio': instance.autoSelectAudio,
|
||||
'defaultAudioAccessibility': instance.defaultAudioAccessibility,
|
||||
'defaultAudioLanguage': instance.defaultAudioLanguage,
|
||||
'defaultAudioLanguages': instance.defaultAudioLanguages,
|
||||
'defaultSubtitleLanguage': instance.defaultSubtitleLanguage,
|
||||
'defaultSubtitleLanguages': instance.defaultSubtitleLanguages,
|
||||
'autoSelectSubtitle': instance.autoSelectSubtitle,
|
||||
'defaultSubtitleAccessibility': instance.defaultSubtitleAccessibility,
|
||||
'defaultSubtitleForced': instance.defaultSubtitleForced,
|
||||
'watchedIndicator': instance.watchedIndicator,
|
||||
'mediaReviewsVisibility': instance.mediaReviewsVisibility,
|
||||
'mediaReviewsLanguages': instance.mediaReviewsLanguages,
|
||||
};
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
import 'plex_media_info.dart';
|
||||
import 'plex_media_version.dart';
|
||||
import '../../media/media_source_info.dart';
|
||||
import '../../media/media_version.dart';
|
||||
|
||||
/// Consolidated data model containing all information needed for video playback.
|
||||
/// This model combines data from multiple Plex API endpoints to reduce redundant requests.
|
||||
@@ -8,13 +8,13 @@ class PlexVideoPlaybackData {
|
||||
final String? videoUrl;
|
||||
|
||||
/// Media information including audio/subtitle tracks and chapters
|
||||
final PlexMediaInfo? mediaInfo;
|
||||
final MediaSourceInfo? mediaInfo;
|
||||
|
||||
/// Available media versions/qualities for this content
|
||||
final List<PlexMediaVersion> availableVersions;
|
||||
final List<MediaVersion> availableVersions;
|
||||
|
||||
/// Markers for intro/credits skip functionality
|
||||
final List<PlexMarker> markers;
|
||||
final List<MediaMarker> markers;
|
||||
|
||||
PlexVideoPlaybackData({
|
||||
required this.videoUrl,
|
||||
@@ -1,38 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_filter.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexFilter _$PlexFilterFromJson(Map<String, dynamic> json) => PlexFilter(
|
||||
filter: json['filter'] as String? ?? '',
|
||||
filterType: json['filterType'] as String? ?? 'string',
|
||||
key: json['key'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'filter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexFilterToJson(PlexFilter instance) =>
|
||||
<String, dynamic>{
|
||||
'filter': instance.filter,
|
||||
'filterType': instance.filterType,
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': instance.type,
|
||||
};
|
||||
|
||||
PlexFilterValue _$PlexFilterValueFromJson(Map<String, dynamic> json) =>
|
||||
PlexFilterValue(
|
||||
key: json['key'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$PlexFilterValueToJson(PlexFilterValue instance) =>
|
||||
<String, dynamic>{
|
||||
'key': instance.key,
|
||||
'title': instance.title,
|
||||
'type': ?instance.type,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user