Merge remote-tracking branch 'upstream/main' into feature/expand-episodes

This commit is contained in:
Micah Morrison
2026-02-27 16:47:51 -05:00
150 changed files with 3800 additions and 2780 deletions
+184
View File
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../widgets/app_icon.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
/// Describes a single action button for use in [FocusableActionBar].
class FocusableAction {
/// Icon to display. Ignored when [child] is provided.
final IconData icon;
/// Icon color. Ignored when [child] is provided.
final Color? iconColor;
final String? tooltip;
final VoidCallback? onPressed;
/// Optional custom child widget placed inside the focus container.
/// Overrides the default [IconButton] built from [icon]/[tooltip]/[onPressed].
final Widget? child;
const FocusableAction({
this.icon = Icons.circle,
this.iconColor,
this.tooltip,
this.onPressed,
this.child,
});
}
/// A row of focusable action buttons for app bar [actions:].
///
/// Manages focus nodes, left/right D-pad navigation between buttons,
/// and the standard white-alpha background focus indicator internally.
///
/// Returns a single [Row] widget — place it inside the `actions:` list:
/// ```dart
/// CustomAppBar(
/// title: Text('Title'),
/// actions: [
/// FocusableActionBar(
/// actions: [
/// FocusableAction(icon: Symbols.refresh_rounded, onPressed: _refresh),
/// FocusableAction(icon: Symbols.upload_rounded, onPressed: _upload),
/// ],
/// ),
/// ],
/// )
/// ```
class FocusableActionBar extends StatefulWidget {
final List<FocusableAction> actions;
/// Called when the user presses down from any action button.
final VoidCallback? onNavigateDown;
/// Called when the user presses up from any action button.
final VoidCallback? onNavigateUp;
/// Called when the user presses left from the leftmost button.
final VoidCallback? onNavigateLeft;
/// Called when the user presses right from the rightmost button.
final VoidCallback? onNavigateRight;
/// Called when the user presses the back key while an action is focused.
final VoidCallback? onBack;
const FocusableActionBar({
super.key,
required this.actions,
this.onNavigateDown,
this.onNavigateUp,
this.onNavigateLeft,
this.onNavigateRight,
this.onBack,
});
@override
State<FocusableActionBar> createState() => FocusableActionBarState();
}
class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _focusStates;
/// Access a focus node by index (e.g. for external `requestFocus()` calls).
FocusNode getFocusNode(int index) => _focusNodes[index];
@override
void initState() {
super.initState();
_initNodes();
}
@override
void didUpdateWidget(FocusableActionBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.actions.length != widget.actions.length) {
_disposeNodes();
_initNodes();
}
}
void _initNodes() {
_focusNodes = List.generate(widget.actions.length, (i) => FocusNode(debugLabel: 'ActionBar[$i]'));
_focusStates = List.filled(widget.actions.length, false);
for (var i = 0; i < _focusNodes.length; i++) {
final idx = i;
_focusNodes[i].addListener(() {
final hasFocus = _focusNodes[idx].hasFocus;
if (_focusStates[idx] != hasFocus) {
setState(() => _focusStates[idx] = hasFocus);
}
});
}
}
void _disposeNodes() {
for (final node in _focusNodes) {
node.dispose();
}
}
@override
void dispose() {
_disposeNodes();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isKeyboard = InputModeTracker.isKeyboardMode(context);
final duration = FocusTheme.getAnimationDuration(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration),
],
);
}
Widget _buildButton(int index, bool isKeyboard, Duration duration) {
final action = widget.actions[index];
final isFocused = _focusStates[index];
final showFocus = isFocused && isKeyboard;
final opacity = isKeyboard && !isFocused ? 0.6 : 1.0;
return Focus(
focusNode: _focusNodes[index],
onKeyEvent: (node, event) {
if (widget.onBack != null) {
final backResult = handleBackKeyAction(event, widget.onBack!);
if (backResult != KeyEventResult.ignored) return backResult;
}
return dpadKeyHandler(
onSelect: action.onPressed,
onLeft: index > 0
? () => _focusNodes[index - 1].requestFocus()
: widget.onNavigateLeft,
onRight: index < _focusNodes.length - 1
? () => _focusNodes[index + 1].requestFocus()
: widget.onNavigateRight,
onDown: widget.onNavigateDown,
onUp: widget.onNavigateUp,
)(node, event);
},
child: AnimatedOpacity(
opacity: showFocus ? 1.0 : opacity,
duration: duration,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
child: action.child ??
IconButton(
icon: AppIcon(action.icon, fill: 1, color: action.iconColor),
tooltip: action.tooltip,
onPressed: action.onPressed,
),
),
),
);
}
}
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Möchtest du die App wirklich beenden?",
"dontAskAgain": "Nicht erneut fragen",
"exit": "Beenden",
"viewAll": "Alle anzeigen"
"viewAll": "Alle anzeigen",
"checkingNetwork": "Netzwerk wird geprüft...",
"refreshingServers": "Server werden aktualisiert...",
"loadingServers": "Server werden geladen...",
"connectingToServers": "Verbindung zu Servern...",
"startingOfflineMode": "Offlinemodus wird gestartet..."
},
"screens": {
"licenses": "Lizenzen",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an",
"showUnwatchedCount": "Anzahl nicht gesehener Folgen anzeigen",
"showUnwatchedCountDescription": "Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an",
"hideSpoilers": "Spoiler für nicht gesehene Episoden verbergen",
"hideSpoilersDescription": "Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden",
"playerBackend": "Player-Backend",
"exoPlayer": "ExoPlayer (Empfohlen)",
"exoPlayerDescription": "Android-nativer Player mit besserer Hardware-Unterstützung",
@@ -266,8 +273,9 @@
"goToSeason": "Zur Staffel",
"shufflePlay": "Zufallswiedergabe",
"fileInfo": "Dateiinfo",
"confirmDelete": "Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?",
"deleteMultipleWarning": "Mehrere Elemente können gelöscht werden.",
"deleteFromServer": "Vom Server löschen",
"confirmDelete": "Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.",
"deleteMultipleWarning": "Dies umfasst alle Episoden und deren Dateien.",
"mediaDeletedSuccessfully": "Medienelement erfolgreich gelöscht",
"mediaFailedToDelete": "Löschen des Medienelements fehlgeschlagen",
"rate": "Bewerten"
@@ -732,11 +740,8 @@
"minimize": "Minimieren"
},
"pairing": {
"recent": "Zuletzt",
"scan": "Scannen",
"manual": "Manuell",
"recentConnections": "Letzte Verbindungen",
"quickReconnect": "Schnell mit zuvor gekoppelten Geräten verbinden",
"pairWithDesktop": "Mit Desktop koppeln",
"enterSessionDetails": "Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.",
"cameraError": "Kamera konnte nicht gestartet werden: ${error}",
"scanInstruction": "Richte deine Kamera auf den QR-Code auf deinem Desktop",
"noRecentConnections": "Keine letzten Verbindungen",
"connectUsingManual": "Verbinde dich über die manuelle Eingabe, um loszulegen",
"invalidQrCode": "Ungültiges QR-Code-Format",
"removeRecentConnection": "Letzte Verbindung entfernen",
"removeConfirm": "\"${name}\" aus den letzten Verbindungen entfernen?",
"validationHostRequired": "Bitte Host-Adresse eingeben",
"validationHostFormat": "Format muss IP:Port sein (z.B. 192.168.1.100:48632)",
"validationSessionIdRequired": "Bitte Sitzungs-ID eingeben",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN muss 6 Ziffern haben",
"connectionTimedOut": "Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.",
"sessionNotFound": "Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.",
"failedToConnect": "Verbindung fehlgeschlagen: ${error}",
"failedToLoadRecent": "Letzte Sitzungen konnten nicht geladen werden: ${error}"
"failedToConnect": "Verbindung fehlgeschlagen: ${error}"
},
"remote": {
"disconnectConfirm": "Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?",
@@ -806,7 +806,8 @@
"subtitleSync": "Untertitel-Synchronisation",
"hdr": "HDR",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige"
"performanceOverlay": "Leistungsanzeige",
"audioPassthrough": "Audio-Durchleitung"
},
"externalPlayer": {
"title": "Externer Player",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Are you sure you want to exit?",
"dontAskAgain": "Don't ask again",
"exit": "Exit",
"viewAll": "View All"
"viewAll": "View All",
"checkingNetwork": "Checking network...",
"refreshingServers": "Refreshing servers...",
"loadingServers": "Loading servers...",
"connectingToServers": "Connecting to servers...",
"startingOfflineMode": "Starting offline mode..."
},
"screens": {
"licenses": "Licenses",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Sidebar stays expanded and content area adjusts to fit",
"showUnwatchedCount": "Show Unwatched Count",
"showUnwatchedCountDescription": "Display unwatched episode count on shows and seasons",
"hideSpoilers": "Hide Spoilers for Unwatched Episodes",
"hideSpoilersDescription": "Blur thumbnails and hide descriptions for episodes you haven't watched yet",
"playerBackend": "Player Backend",
"exoPlayer": "ExoPlayer (Recommended)",
"exoPlayerDescription": "Android native player with better hardware support",
@@ -266,8 +273,9 @@
"goToSeason": "Go to season",
"shufflePlay": "Shuffle Play",
"fileInfo": "File Info",
"confirmDelete": "Are you sure you want to delete this item from your filesystem?",
"deleteMultipleWarning": "Multiple items may be deleted.",
"deleteFromServer": "Delete from server",
"confirmDelete": "This will permanently delete this media and its files from your server. This cannot be undone.",
"deleteMultipleWarning": "This includes all episodes and their files.",
"mediaDeletedSuccessfully": "Media item deleted successfully",
"mediaFailedToDelete": "Failed to delete media item",
"rate": "Rate"
@@ -732,11 +740,8 @@
"minimize": "Minimize"
},
"pairing": {
"recent": "Recent",
"scan": "Scan",
"manual": "Manual",
"recentConnections": "Recent Connections",
"quickReconnect": "Quickly reconnect to previously paired devices",
"pairWithDesktop": "Pair with Desktop",
"enterSessionDetails": "Enter the session details shown on your desktop device",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.",
"cameraError": "Could not start camera: ${error}",
"scanInstruction": "Point your camera at the QR code shown on your desktop",
"noRecentConnections": "No recent connections",
"connectUsingManual": "Connect to a device using Manual entry to get started",
"invalidQrCode": "Invalid QR code format",
"removeRecentConnection": "Remove Recent Connection",
"removeConfirm": "Remove \"${name}\" from recent connections?",
"validationHostRequired": "Please enter host address",
"validationHostFormat": "Format must be IP:port (e.g., 192.168.1.100:48632)",
"validationSessionIdRequired": "Please enter a session ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN must be 6 digits",
"connectionTimedOut": "Connection timed out. Please check the session ID and PIN.",
"sessionNotFound": "Could not find the session. Please check your credentials.",
"failedToConnect": "Failed to connect: ${error}",
"failedToLoadRecent": "Failed to load recent sessions: ${error}"
"failedToConnect": "Failed to connect: ${error}"
},
"remote": {
"disconnectConfirm": "Do you want to disconnect from the remote session?",
@@ -806,7 +806,8 @@
"subtitleSync": "Subtitle Sync",
"hdr": "HDR",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay"
"performanceOverlay": "Performance Overlay",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "External Player",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "¿Estás seguro de que quieres salir?",
"dontAskAgain": "No volver a preguntar",
"exit": "Salir",
"viewAll": "Ver todo"
"viewAll": "Ver todo",
"checkingNetwork": "Comprobando red...",
"refreshingServers": "Actualizando servidores...",
"loadingServers": "Cargando servidores...",
"connectingToServers": "Conectando a servidores...",
"startingOfflineMode": "Iniciando modo sin conexión..."
},
"screens": {
"licenses": "Licencias",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse",
"showUnwatchedCount": "Mostrar conteo de no vistos",
"showUnwatchedCountDescription": "Mostrar el conteo de episodios no vistos en series y temporadas",
"hideSpoilers": "Ocultar spoilers de episodios no vistos",
"hideSpoilersDescription": "Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto",
"playerBackend": "Reproductor",
"exoPlayer": "ExoPlayer (Recomendado)",
"exoPlayerDescription": "Reproductor nativo de Android con mejor soporte de hardware",
@@ -266,8 +273,9 @@
"goToSeason": "Ir a la temporada",
"shufflePlay": "Reproducción Aleatoria",
"fileInfo": "Información del Archivo",
"confirmDelete": "¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?",
"deleteMultipleWarning": "Es posible que se eliminen varios elementos.",
"deleteFromServer": "Eliminar del servidor",
"confirmDelete": "Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.",
"deleteMultipleWarning": "Esto incluye todos los episodios y sus archivos.",
"mediaDeletedSuccessfully": "Elemento multimedia eliminado con éxito",
"mediaFailedToDelete": "Error al eliminar el elemento multimedia",
"rate": "Calificar"
@@ -732,11 +740,8 @@
"minimize": "Minimizar"
},
"pairing": {
"recent": "Recientes",
"scan": "Escanear",
"manual": "Manual",
"recentConnections": "Conexiones recientes",
"quickReconnect": "Reconectar rápidamente con dispositivos emparejados anteriormente",
"pairWithDesktop": "Emparejar con escritorio",
"enterSessionDetails": "Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.",
"cameraError": "No se pudo iniciar la cámara: ${error}",
"scanInstruction": "Apunta tu cámara al código QR que aparece en tu escritorio",
"noRecentConnections": "No hay conexiones recientes",
"connectUsingManual": "Conéctate a un dispositivo usando la entrada manual para empezar",
"invalidQrCode": "Formato de código QR no válido",
"removeRecentConnection": "Eliminar conexión reciente",
"removeConfirm": "¿Eliminar \"${name}\" de las conexiones recientes?",
"validationHostRequired": "Por favor, introduce la dirección del host",
"validationHostFormat": "El formato debe ser IP:puerto (ej., 192.168.1.100:48632)",
"validationSessionIdRequired": "Por favor, introduce un ID de sesión",
@@ -763,8 +764,7 @@
"validationPinLength": "El PIN debe tener 6 dígitos",
"connectionTimedOut": "Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.",
"sessionNotFound": "No se encontró la sesión. Verifica tus credenciales.",
"failedToConnect": "Error al conectar: ${error}",
"failedToLoadRecent": "Error al cargar sesiones recientes: ${error}"
"failedToConnect": "Error al conectar: ${error}"
},
"remote": {
"disconnectConfirm": "¿Quieres desconectarte de la sesión remota?",
@@ -806,7 +806,8 @@
"subtitleSync": "Sincronización de subtítulos",
"hdr": "HDR",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento"
"performanceOverlay": "Indicador de rendimiento",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "Reproductor externo",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Êtes-vous sûr de vouloir quitter ?",
"dontAskAgain": "Ne plus demander",
"exit": "Quitter",
"viewAll": "Tout afficher"
"viewAll": "Tout afficher",
"checkingNetwork": "Vérification du réseau...",
"refreshingServers": "Actualisation des serveurs...",
"loadingServers": "Chargement des serveurs...",
"connectingToServers": "Connexion aux serveurs...",
"startingOfflineMode": "Démarrage en mode hors-ligne..."
},
"screens": {
"licenses": "Licenses",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barre latérale reste étendue et la zone de contenu s'adapte",
"showUnwatchedCount": "Afficher le nombre non visionné",
"showUnwatchedCountDescription": "Afficher le nombre d'épisodes non visionnés pour les séries et saisons",
"hideSpoilers": "Masquer les spoilers des épisodes non vus",
"hideSpoilersDescription": "Flouter les miniatures et masquer les descriptions des épisodes que vous n'avez pas encore regardés",
"playerBackend": "Moteur de lecture",
"exoPlayer": "ExoPlayer (Recommandé)",
"exoPlayerDescription": "Lecteur natif Android avec meilleur support matériel",
@@ -266,8 +273,9 @@
"goToSeason": "Aller à la saison",
"shufflePlay": "Lecture aléatoire",
"fileInfo": "Informations sur le fichier",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?",
"deleteMultipleWarning": "Plusieurs éléments peuvent être supprimés.",
"deleteFromServer": "Supprimer du serveur",
"confirmDelete": "Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.",
"deleteMultipleWarning": "Cela inclut tous les épisodes et leurs fichiers.",
"mediaDeletedSuccessfully": "Élément média supprimé avec succès",
"mediaFailedToDelete": "Échec de la suppression de l'élément média",
"rate": "Noter"
@@ -732,11 +740,8 @@
"minimize": "Réduire"
},
"pairing": {
"recent": "Récents",
"scan": "Scanner",
"manual": "Manuel",
"recentConnections": "Connexions récentes",
"quickReconnect": "Reconnexion rapide aux appareils précédemment jumelés",
"pairWithDesktop": "Jumeler avec un bureau",
"enterSessionDetails": "Saisissez les détails de la session affichés sur votre appareil de bureau",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "L'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l'accès à la caméra dans les paramètres de votre appareil.",
"cameraError": "Impossible de démarrer la caméra : ${error}",
"scanInstruction": "Pointez votre caméra vers le QR code affiché sur votre bureau",
"noRecentConnections": "Aucune connexion récente",
"connectUsingManual": "Connectez-vous à un appareil via la saisie manuelle pour commencer",
"invalidQrCode": "Format de QR code invalide",
"removeRecentConnection": "Supprimer la connexion récente",
"removeConfirm": "Supprimer \"${name}\" des connexions récentes ?",
"validationHostRequired": "Veuillez saisir l'adresse de l'hôte",
"validationHostFormat": "Le format doit être IP:port (ex : 192.168.1.100:48632)",
"validationSessionIdRequired": "Veuillez saisir un ID de session",
@@ -763,8 +764,7 @@
"validationPinLength": "Le PIN doit contenir 6 chiffres",
"connectionTimedOut": "Délai de connexion expiré. Veuillez vérifier l'ID de session et le PIN.",
"sessionNotFound": "Session introuvable. Veuillez vérifier vos identifiants.",
"failedToConnect": "Échec de la connexion : ${error}",
"failedToLoadRecent": "Échec du chargement des sessions récentes : ${error}"
"failedToConnect": "Échec de la connexion : ${error}"
},
"remote": {
"disconnectConfirm": "Voulez-vous vous déconnecter de la session distante ?",
@@ -806,7 +806,8 @@
"subtitleSync": "Synchronisation des sous-titres",
"hdr": "HDR",
"audioOutput": "Sortie audio",
"performanceOverlay": "Superposition de performance"
"performanceOverlay": "Superposition de performance",
"audioPassthrough": "Audio Pass-Through"
},
"externalPlayer": {
"title": "Lecteur externe",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Sei sicuro di voler uscire?",
"dontAskAgain": "Non chiedere più",
"exit": "Esci",
"viewAll": "Mostra tutto"
"viewAll": "Mostra tutto",
"checkingNetwork": "Verifica rete...",
"refreshingServers": "Aggiornamento server...",
"loadingServers": "Caricamento server...",
"connectingToServers": "Connessione ai server...",
"startingOfflineMode": "Avvio modalità offline..."
},
"screens": {
"licenses": "Licenze",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "La barra laterale rimane espansa e l'area del contenuto si adatta",
"showUnwatchedCount": "Mostra conteggio non visti",
"showUnwatchedCountDescription": "Mostra il numero di episodi non visti per serie e stagioni",
"hideSpoilers": "Nascondi spoiler per episodi non visti",
"hideSpoilersDescription": "Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato",
"playerBackend": "Motore di riproduzione",
"exoPlayer": "ExoPlayer (Consigliato)",
"exoPlayerDescription": "Lettore nativo Android con migliore supporto hardware",
@@ -266,8 +273,9 @@
"goToSeason": "Vai alla stagione",
"shufflePlay": "Riproduzione casuale",
"fileInfo": "Info sul file",
"confirmDelete": "Sei sicuro di voler eliminare questo elemento dal tuo filesystem?",
"deleteMultipleWarning": "Potrebbero essere eliminati più elementi.",
"deleteFromServer": "Elimina dal server",
"confirmDelete": "Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.",
"deleteMultipleWarning": "Questo include tutti gli episodi e i loro file.",
"mediaDeletedSuccessfully": "Elemento multimediale eliminato con successo",
"mediaFailedToDelete": "Impossibile eliminare l'elemento multimediale",
"rate": "Valuta"
@@ -732,11 +740,8 @@
"minimize": "Riduci"
},
"pairing": {
"recent": "Recenti",
"scan": "Scansiona",
"manual": "Manuale",
"recentConnections": "Connessioni recenti",
"quickReconnect": "Riconnettiti rapidamente ai dispositivi associati in precedenza",
"pairWithDesktop": "Associa con desktop",
"enterSessionDetails": "Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "L'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l'accesso alla fotocamera nelle impostazioni del dispositivo.",
"cameraError": "Impossibile avviare la fotocamera: ${error}",
"scanInstruction": "Punta la fotocamera verso il QR code mostrato sul tuo desktop",
"noRecentConnections": "Nessuna connessione recente",
"connectUsingManual": "Connettiti a un dispositivo tramite inserimento manuale per iniziare",
"invalidQrCode": "Formato QR code non valido",
"removeRecentConnection": "Rimuovi connessione recente",
"removeConfirm": "Rimuovere \"${name}\" dalle connessioni recenti?",
"validationHostRequired": "Inserisci l'indirizzo host",
"validationHostFormat": "Il formato deve essere IP:porta (es. 192.168.1.100:48632)",
"validationSessionIdRequired": "Inserisci un ID sessione",
@@ -763,8 +764,7 @@
"validationPinLength": "Il PIN deve essere di 6 cifre",
"connectionTimedOut": "Connessione scaduta. Verifica l'ID sessione e il PIN.",
"sessionNotFound": "Sessione non trovata. Verifica le tue credenziali.",
"failedToConnect": "Connessione fallita: ${error}",
"failedToLoadRecent": "Impossibile caricare le sessioni recenti: ${error}"
"failedToConnect": "Connessione fallita: ${error}"
},
"remote": {
"disconnectConfirm": "Vuoi disconnetterti dalla sessione remota?",
@@ -806,7 +806,8 @@
"subtitleSync": "Sincronizzazione sottotitoli",
"hdr": "HDR",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni"
"performanceOverlay": "Overlay prestazioni",
"audioPassthrough": "Audio Passthrough"
},
"externalPlayer": {
"title": "Lettore esterno",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "정말 종료하시겠습니까?",
"dontAskAgain": "다시 묻지 않기",
"exit": "종료",
"viewAll": "모두 보기"
"viewAll": "모두 보기",
"checkingNetwork": "네트워크 확인 중...",
"refreshingServers": "서버 새로고침 중...",
"loadingServers": "서버 로딩 중...",
"connectingToServers": "서버 연결 중...",
"startingOfflineMode": "오프라인 모드 시작 중..."
},
"screens": {
"licenses": "라이선스",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다",
"showUnwatchedCount": "미시청 수 표시",
"showUnwatchedCountDescription": "시리즈 및 시즌에 미시청 에피소드 수 표시",
"hideSpoilers": "미시청 에피소드 스포일러 숨기기",
"hideSpoilersDescription": "아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다",
"playerBackend": "플레이어 백엔드",
"exoPlayer": "ExoPlayer (권장)",
"exoPlayerDescription": "더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어",
@@ -266,8 +273,9 @@
"goToSeason": "시즌으로 이동",
"shufflePlay": "무작위 재생",
"fileInfo": "파일 정보",
"confirmDelete": "파일 시스템에서 이 항목을 삭제하시겠습니까?",
"deleteMultipleWarning": "여러 항목이 삭제될습니다.",
"deleteFromServer": "서버에서 삭제",
"confirmDelete": "이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.",
"deleteMultipleWarning": "모든 에피소드와 파일이 포함됩니다.",
"mediaDeletedSuccessfully": "미디어 항목이 성공적으로 삭제되었습니다",
"mediaFailedToDelete": "미디어 항목 삭제 실패",
"rate": "평가"
@@ -732,11 +740,8 @@
"minimize": "최소화"
},
"pairing": {
"recent": "최근",
"scan": "스캔",
"manual": "수동",
"recentConnections": "최근 연결",
"quickReconnect": "이전에 페어링한 기기에 빠르게 재연결",
"pairWithDesktop": "데스크톱과 페어링",
"enterSessionDetails": "데스크톱 기기에 표시된 세션 정보를 입력하세요",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.",
"cameraError": "카메라를 시작할 수 없습니다: ${error}",
"scanInstruction": "데스크톱에 표시된 QR 코드에 카메라를 향하세요",
"noRecentConnections": "최근 연결 없음",
"connectUsingManual": "수동 입력으로 기기에 연결하여 시작하세요",
"invalidQrCode": "유효하지 않은 QR 코드 형식",
"removeRecentConnection": "최근 연결 삭제",
"removeConfirm": "\"${name}\"을(를) 최근 연결에서 삭제하시겠습니까?",
"validationHostRequired": "호스트 주소를 입력하세요",
"validationHostFormat": "IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)",
"validationSessionIdRequired": "세션 ID를 입력하세요",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN은 6자리여야 합니다",
"connectionTimedOut": "연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.",
"sessionNotFound": "세션을 찾을 수 없습니다. 자격 증명을 확인하세요.",
"failedToConnect": "연결 실패: ${error}",
"failedToLoadRecent": "최근 세션 로드 실패: ${error}"
"failedToConnect": "연결 실패: ${error}"
},
"remote": {
"disconnectConfirm": "원격 세션 연결을 해제하시겠습니까?",
@@ -806,7 +806,8 @@
"subtitleSync": "자막 동기화",
"hdr": "HDR",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이"
"performanceOverlay": "성능 오버레이",
"audioPassthrough": "오디오 패스스루"
},
"externalPlayer": {
"title": "외부 플레이어",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Weet je zeker dat je wilt afsluiten?",
"dontAskAgain": "Niet meer vragen",
"exit": "Afsluiten",
"viewAll": "Alles weergeven"
"viewAll": "Alles weergeven",
"checkingNetwork": "Netwerk controleren...",
"refreshingServers": "Servers vernieuwen...",
"loadingServers": "Servers laden...",
"connectingToServers": "Verbinden met servers...",
"startingOfflineMode": "Offlinemodus starten..."
},
"screens": {
"licenses": "Licenties",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan",
"showUnwatchedCount": "Aantal ongekeken tonen",
"showUnwatchedCountDescription": "Toon aantal ongekeken afleveringen bij series en seizoenen",
"hideSpoilers": "Spoilers voor ongekeken afleveringen verbergen",
"hideSpoilersDescription": "Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien",
"playerBackend": "Speler backend",
"exoPlayer": "ExoPlayer (Aanbevolen)",
"exoPlayerDescription": "Android-native speler met betere hardware-ondersteuning",
@@ -266,8 +273,9 @@
"goToSeason": "Ga naar seizoen",
"shufflePlay": "Willekeurig afspelen",
"fileInfo": "Bestand info",
"confirmDelete": "Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?",
"deleteMultipleWarning": "Meerdere items kunnen worden verwijderd.",
"deleteFromServer": "Verwijderen van server",
"confirmDelete": "Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.",
"deleteMultipleWarning": "Dit omvat alle afleveringen en hun bestanden.",
"mediaDeletedSuccessfully": "Media-item succesvol verwijderd",
"mediaFailedToDelete": "Verwijderen van media-item mislukt",
"rate": "Beoordelen"
@@ -732,11 +740,8 @@
"minimize": "Minimaliseren"
},
"pairing": {
"recent": "Recent",
"scan": "Scannen",
"manual": "Handmatig",
"recentConnections": "Recente verbindingen",
"quickReconnect": "Snel opnieuw verbinden met eerder gekoppelde apparaten",
"pairWithDesktop": "Koppelen met desktop",
"enterSessionDetails": "Voer de sessiegegevens in die op je desktop-apparaat worden getoond",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.",
"cameraError": "Kan camera niet starten: ${error}",
"scanInstruction": "Richt je camera op de QR-code die op je desktop wordt getoond",
"noRecentConnections": "Geen recente verbindingen",
"connectUsingManual": "Verbind met een apparaat via Handmatige invoer om te beginnen",
"invalidQrCode": "Ongeldig QR-codeformaat",
"removeRecentConnection": "Recente verbinding verwijderen",
"removeConfirm": "\"${name}\" verwijderen uit recente verbindingen?",
"validationHostRequired": "Voer een hostadres in",
"validationHostFormat": "Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)",
"validationSessionIdRequired": "Voer een sessie-ID in",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN moet 6 cijfers zijn",
"connectionTimedOut": "Verbinding verlopen. Controleer de sessie-ID en PIN.",
"sessionNotFound": "Kan de sessie niet vinden. Controleer je gegevens.",
"failedToConnect": "Verbinden mislukt: ${error}",
"failedToLoadRecent": "Kan recente sessies niet laden: ${error}"
"failedToConnect": "Verbinden mislukt: ${error}"
},
"remote": {
"disconnectConfirm": "Wil je de verbinding met de externe sessie verbreken?",
@@ -806,7 +806,8 @@
"subtitleSync": "Ondertitel synchronisatie",
"hdr": "HDR",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay"
"performanceOverlay": "Prestatie-overlay",
"audioPassthrough": "Audio-doorvoer"
},
"externalPlayer": {
"title": "Externe speler",
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonDe implements TranslationsCommonEn {
@override String get dontAskAgain => 'Nicht erneut fragen';
@override String get exit => 'Beenden';
@override String get viewAll => 'Alle anzeigen';
@override String get checkingNetwork => 'Netzwerk wird geprüft...';
@override String get refreshingServers => 'Server werden aktualisiert...';
@override String get loadingServers => 'Server werden geladen...';
@override String get connectingToServers => 'Verbindung zu Servern...';
@override String get startingOfflineMode => 'Offlinemodus wird gestartet...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsDe implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an';
@override String get showUnwatchedCount => 'Anzahl nicht gesehener Folgen anzeigen';
@override String get showUnwatchedCountDescription => 'Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an';
@override String get hideSpoilers => 'Spoiler für nicht gesehene Episoden verbergen';
@override String get hideSpoilersDescription => 'Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden';
@override String get playerBackend => 'Player-Backend';
@override String get exoPlayer => 'ExoPlayer (Empfohlen)';
@override String get exoPlayerDescription => 'Android-nativer Player mit besserer Hardware-Unterstützung';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuDe implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Zur Staffel';
@override String get shufflePlay => 'Zufallswiedergabe';
@override String get fileInfo => 'Dateiinfo';
@override String get confirmDelete => 'Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?';
@override String get deleteMultipleWarning => 'Mehrere Elemente können gelöscht werden.';
@override String get deleteFromServer => 'Vom Server löschen';
@override String get confirmDelete => 'Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override String get deleteMultipleWarning => 'Dies umfasst alle Episoden und deren Dateien.';
@override String get mediaDeletedSuccessfully => 'Medienelement erfolgreich gelöscht';
@override String get mediaFailedToDelete => 'Löschen des Medienelements fehlgeschlagen';
@override String get rate => 'Bewerten';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsDe implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audioausgabe';
@override String get performanceOverlay => 'Leistungsanzeige';
@override String get audioPassthrough => 'Audio-Durchleitung';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
final TranslationsDe _root; // ignore: unused_field
// Translations
@override String get recent => 'Zuletzt';
@override String get scan => 'Scannen';
@override String get manual => 'Manuell';
@override String get recentConnections => 'Letzte Verbindungen';
@override String get quickReconnect => 'Schnell mit zuvor gekoppelten Geräten verbinden';
@override String get pairWithDesktop => 'Mit Desktop koppeln';
@override String get enterSessionDetails => 'Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.';
@override String cameraError({required Object error}) => 'Kamera konnte nicht gestartet werden: ${error}';
@override String get scanInstruction => 'Richte deine Kamera auf den QR-Code auf deinem Desktop';
@override String get noRecentConnections => 'Keine letzten Verbindungen';
@override String get connectUsingManual => 'Verbinde dich über die manuelle Eingabe, um loszulegen';
@override String get invalidQrCode => 'Ungültiges QR-Code-Format';
@override String get removeRecentConnection => 'Letzte Verbindung entfernen';
@override String removeConfirm({required Object name}) => '"${name}" aus den letzten Verbindungen entfernen?';
@override String get validationHostRequired => 'Bitte Host-Adresse eingeben';
@override String get validationHostFormat => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Bitte Sitzungs-ID eingeben';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingDe implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.';
@override String get sessionNotFound => 'Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.';
@override String failedToConnect({required Object error}) => 'Verbindung fehlgeschlagen: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Letzte Sitzungen konnten nicht geladen werden: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsDe {
'common.dontAskAgain' => 'Nicht erneut fragen',
'common.exit' => 'Beenden',
'common.viewAll' => 'Alle anzeigen',
'common.checkingNetwork' => 'Netzwerk wird geprüft...',
'common.refreshingServers' => 'Server werden aktualisiert...',
'common.loadingServers' => 'Server werden geladen...',
'common.connectingToServers' => 'Verbindung zu Servern...',
'common.startingOfflineMode' => 'Offlinemodus wird gestartet...',
'screens.licenses' => 'Lizenzen',
'screens.switchProfile' => 'Profil wechseln',
'screens.subtitleStyling' => 'Untertitel-Stil',
@@ -1401,6 +1407,8 @@ extension on TranslationsDe {
'settings.alwaysKeepSidebarOpenDescription' => 'Seitenleiste bleibt erweitert und Inhaltsbereich passt sich an',
'settings.showUnwatchedCount' => 'Anzahl nicht gesehener Folgen anzeigen',
'settings.showUnwatchedCountDescription' => 'Zeigt die Anzahl nicht gesehener Episoden bei Serien und Staffeln an',
'settings.hideSpoilers' => 'Spoiler für nicht gesehene Episoden verbergen',
'settings.hideSpoilersDescription' => 'Vorschaubilder unscharf machen und Beschreibungen für noch nicht gesehene Episoden ausblenden',
'settings.playerBackend' => 'Player-Backend',
'settings.exoPlayer' => 'ExoPlayer (Empfohlen)',
'settings.exoPlayerDescription' => 'Android-nativer Player mit besserer Hardware-Unterstützung',
@@ -1538,8 +1546,9 @@ extension on TranslationsDe {
'mediaMenu.goToSeason' => 'Zur Staffel',
'mediaMenu.shufflePlay' => 'Zufallswiedergabe',
'mediaMenu.fileInfo' => 'Dateiinfo',
'mediaMenu.confirmDelete' => 'Sind Sie sicher, dass Sie dieses Element aus Ihrem Dateisystem löschen möchten?',
'mediaMenu.deleteMultipleWarning' => 'Mehrere Elemente können gelöscht werden.',
'mediaMenu.deleteFromServer' => 'Vom Server löschen',
'mediaMenu.confirmDelete' => 'Dieses Medium und seine Dateien werden dauerhaft von Ihrem Server gelöscht. Dies kann nicht rückgängig gemacht werden.',
'mediaMenu.deleteMultipleWarning' => 'Dies umfasst alle Episoden und deren Dateien.',
'mediaMenu.mediaDeletedSuccessfully' => 'Medienelement erfolgreich gelöscht',
'mediaMenu.mediaFailedToDelete' => 'Löschen des Medienelements fehlgeschlagen',
'mediaMenu.rate' => 'Bewerten',
@@ -1949,11 +1958,8 @@ extension on TranslationsDe {
'companionRemote.session.copyToClipboard' => 'In Zwischenablage kopieren',
'companionRemote.session.newSession' => 'Neue Sitzung',
'companionRemote.session.minimize' => 'Minimieren',
'companionRemote.pairing.recent' => 'Zuletzt',
'companionRemote.pairing.scan' => 'Scannen',
'companionRemote.pairing.manual' => 'Manuell',
'companionRemote.pairing.recentConnections' => 'Letzte Verbindungen',
'companionRemote.pairing.quickReconnect' => 'Schnell mit zuvor gekoppelten Geräten verbinden',
'companionRemote.pairing.pairWithDesktop' => 'Mit Desktop koppeln',
'companionRemote.pairing.enterSessionDetails' => 'Gib die Sitzungsdetails ein, die auf deinem Desktop-Gerät angezeigt werden',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsDe {
'companionRemote.pairing.cameraPermissionRequired' => 'Kameraberechtigung wird zum Scannen von QR-Codes benötigt.\nBitte erteile den Kamerazugriff in den Geräteeinstellungen.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kamera konnte nicht gestartet werden: ${error}',
'companionRemote.pairing.scanInstruction' => 'Richte deine Kamera auf den QR-Code auf deinem Desktop',
'companionRemote.pairing.noRecentConnections' => 'Keine letzten Verbindungen',
'companionRemote.pairing.connectUsingManual' => 'Verbinde dich über die manuelle Eingabe, um loszulegen',
'companionRemote.pairing.invalidQrCode' => 'Ungültiges QR-Code-Format',
'companionRemote.pairing.removeRecentConnection' => 'Letzte Verbindung entfernen',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}" aus den letzten Verbindungen entfernen?',
'companionRemote.pairing.validationHostRequired' => 'Bitte Host-Adresse eingeben',
'companionRemote.pairing.validationHostFormat' => 'Format muss IP:Port sein (z.B. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Bitte Sitzungs-ID eingeben',
@@ -1981,7 +1983,6 @@ extension on TranslationsDe {
'companionRemote.pairing.connectionTimedOut' => 'Zeitüberschreitung. Bitte Sitzungs-ID und PIN überprüfen.',
'companionRemote.pairing.sessionNotFound' => 'Sitzung nicht gefunden. Bitte Zugangsdaten überprüfen.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbindung fehlgeschlagen: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Letzte Sitzungen konnten nicht geladen werden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Möchtest du die Verbindung zur Fernsteuerungssitzung trennen?',
'companionRemote.remote.reconnecting' => 'Verbindung wird wiederhergestellt...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Versuch ${current} von 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsDe {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audioausgabe',
'videoSettings.performanceOverlay' => 'Leistungsanzeige',
'videoSettings.audioPassthrough' => 'Audio-Durchleitung',
'externalPlayer.title' => 'Externer Player',
'externalPlayer.useExternalPlayer' => 'Externen Player verwenden',
'externalPlayer.useExternalPlayerDescription' => 'Videos in einer externen App statt im integrierten Player öffnen',
+42 -37
View File
@@ -252,6 +252,21 @@ class TranslationsCommonEn {
/// en: 'View All'
String get viewAll => 'View All';
/// en: 'Checking network...'
String get checkingNetwork => 'Checking network...';
/// en: 'Refreshing servers...'
String get refreshingServers => 'Refreshing servers...';
/// en: 'Loading servers...'
String get loadingServers => 'Loading servers...';
/// en: 'Connecting to servers...'
String get connectingToServers => 'Connecting to servers...';
/// en: 'Starting offline mode...'
String get startingOfflineMode => 'Starting offline mode...';
}
// Path: screens
@@ -454,6 +469,12 @@ class TranslationsSettingsEn {
/// en: 'Display unwatched episode count on shows and seasons'
String get showUnwatchedCountDescription => 'Display unwatched episode count on shows and seasons';
/// en: 'Hide Spoilers for Unwatched Episodes'
String get hideSpoilers => 'Hide Spoilers for Unwatched Episodes';
/// en: 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet'
String get hideSpoilersDescription => 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet';
/// en: 'Player Backend'
String get playerBackend => 'Player Backend';
@@ -855,11 +876,14 @@ class TranslationsMediaMenuEn {
/// en: 'File Info'
String get fileInfo => 'File Info';
/// en: 'Are you sure you want to delete this item from your filesystem?'
String get confirmDelete => 'Are you sure you want to delete this item from your filesystem?';
/// en: 'Delete from server'
String get deleteFromServer => 'Delete from server';
/// en: 'Multiple items may be deleted.'
String get deleteMultipleWarning => 'Multiple items may be deleted.';
/// en: 'This will permanently delete this media and its files from your server. This cannot be undone.'
String get confirmDelete => 'This will permanently delete this media and its files from your server. This cannot be undone.';
/// en: 'This includes all episodes and their files.'
String get deleteMultipleWarning => 'This includes all episodes and their files.';
/// en: 'Media item deleted successfully'
String get mediaDeletedSuccessfully => 'Media item deleted successfully';
@@ -2249,6 +2273,9 @@ class TranslationsVideoSettingsEn {
/// en: 'Performance Overlay'
String get performanceOverlay => 'Performance Overlay';
/// en: 'Audio Passthrough'
String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -2691,21 +2718,12 @@ class TranslationsCompanionRemotePairingEn {
// Translations
/// en: 'Recent'
String get recent => 'Recent';
/// en: 'Scan'
String get scan => 'Scan';
/// en: 'Manual'
String get manual => 'Manual';
/// en: 'Recent Connections'
String get recentConnections => 'Recent Connections';
/// en: 'Quickly reconnect to previously paired devices'
String get quickReconnect => 'Quickly reconnect to previously paired devices';
/// en: 'Pair with Desktop'
String get pairWithDesktop => 'Pair with Desktop';
@@ -2745,21 +2763,9 @@ class TranslationsCompanionRemotePairingEn {
/// en: 'Point your camera at the QR code shown on your desktop'
String get scanInstruction => 'Point your camera at the QR code shown on your desktop';
/// en: 'No recent connections'
String get noRecentConnections => 'No recent connections';
/// en: 'Connect to a device using Manual entry to get started'
String get connectUsingManual => 'Connect to a device using Manual entry to get started';
/// en: 'Invalid QR code format'
String get invalidQrCode => 'Invalid QR code format';
/// en: 'Remove Recent Connection'
String get removeRecentConnection => 'Remove Recent Connection';
/// en: 'Remove "${name}" from recent connections?'
String removeConfirm({required Object name}) => 'Remove "${name}" from recent connections?';
/// en: 'Please enter host address'
String get validationHostRequired => 'Please enter host address';
@@ -2787,8 +2793,6 @@ class TranslationsCompanionRemotePairingEn {
/// en: 'Failed to connect: ${error}'
String failedToConnect({required Object error}) => 'Failed to connect: ${error}';
/// en: 'Failed to load recent sessions: ${error}'
String failedToLoadRecent({required Object error}) => 'Failed to load recent sessions: ${error}';
}
// Path: companionRemote.remote
@@ -2944,6 +2948,11 @@ extension on Translations {
'common.dontAskAgain' => 'Don\'t ask again',
'common.exit' => 'Exit',
'common.viewAll' => 'View All',
'common.checkingNetwork' => 'Checking network...',
'common.refreshingServers' => 'Refreshing servers...',
'common.loadingServers' => 'Loading servers...',
'common.connectingToServers' => 'Connecting to servers...',
'common.startingOfflineMode' => 'Starting offline mode...',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Switch Profile',
'screens.subtitleStyling' => 'Subtitle Styling',
@@ -3002,6 +3011,8 @@ extension on Translations {
'settings.alwaysKeepSidebarOpenDescription' => 'Sidebar stays expanded and content area adjusts to fit',
'settings.showUnwatchedCount' => 'Show Unwatched Count',
'settings.showUnwatchedCountDescription' => 'Display unwatched episode count on shows and seasons',
'settings.hideSpoilers' => 'Hide Spoilers for Unwatched Episodes',
'settings.hideSpoilersDescription' => 'Blur thumbnails and hide descriptions for episodes you haven\'t watched yet',
'settings.playerBackend' => 'Player Backend',
'settings.exoPlayer' => 'ExoPlayer (Recommended)',
'settings.exoPlayerDescription' => 'Android native player with better hardware support',
@@ -3139,8 +3150,9 @@ extension on Translations {
'mediaMenu.goToSeason' => 'Go to season',
'mediaMenu.shufflePlay' => 'Shuffle Play',
'mediaMenu.fileInfo' => 'File Info',
'mediaMenu.confirmDelete' => 'Are you sure you want to delete this item from your filesystem?',
'mediaMenu.deleteMultipleWarning' => 'Multiple items may be deleted.',
'mediaMenu.deleteFromServer' => 'Delete from server',
'mediaMenu.confirmDelete' => 'This will permanently delete this media and its files from your server. This cannot be undone.',
'mediaMenu.deleteMultipleWarning' => 'This includes all episodes and their files.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media item deleted successfully',
'mediaMenu.mediaFailedToDelete' => 'Failed to delete media item',
'mediaMenu.rate' => 'Rate',
@@ -3550,11 +3562,8 @@ extension on Translations {
'companionRemote.session.copyToClipboard' => 'Copy to clipboard',
'companionRemote.session.newSession' => 'New Session',
'companionRemote.session.minimize' => 'Minimize',
'companionRemote.pairing.recent' => 'Recent',
'companionRemote.pairing.scan' => 'Scan',
'companionRemote.pairing.manual' => 'Manual',
'companionRemote.pairing.recentConnections' => 'Recent Connections',
'companionRemote.pairing.quickReconnect' => 'Quickly reconnect to previously paired devices',
'companionRemote.pairing.pairWithDesktop' => 'Pair with Desktop',
'companionRemote.pairing.enterSessionDetails' => 'Enter the session details shown on your desktop device',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -3568,11 +3577,7 @@ extension on Translations {
'companionRemote.pairing.cameraPermissionRequired' => 'Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Could not start camera: ${error}',
'companionRemote.pairing.scanInstruction' => 'Point your camera at the QR code shown on your desktop',
'companionRemote.pairing.noRecentConnections' => 'No recent connections',
'companionRemote.pairing.connectUsingManual' => 'Connect to a device using Manual entry to get started',
'companionRemote.pairing.invalidQrCode' => 'Invalid QR code format',
'companionRemote.pairing.removeRecentConnection' => 'Remove Recent Connection',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Remove "${name}" from recent connections?',
'companionRemote.pairing.validationHostRequired' => 'Please enter host address',
'companionRemote.pairing.validationHostFormat' => 'Format must be IP:port (e.g., 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Please enter a session ID',
@@ -3582,7 +3587,6 @@ extension on Translations {
'companionRemote.pairing.connectionTimedOut' => 'Connection timed out. Please check the session ID and PIN.',
'companionRemote.pairing.sessionNotFound' => 'Could not find the session. Please check your credentials.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Failed to connect: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Failed to load recent sessions: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Do you want to disconnect from the remote session?',
'companionRemote.remote.reconnecting' => 'Reconnecting...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Attempt ${current} of 5',
@@ -3620,6 +3624,7 @@ extension on Translations {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'External Player',
'externalPlayer.useExternalPlayer' => 'Use External Player',
'externalPlayer.useExternalPlayerDescription' => 'Open videos in an external app instead of the built-in player',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonEs implements TranslationsCommonEn {
@override String get dontAskAgain => 'No volver a preguntar';
@override String get exit => 'Salir';
@override String get viewAll => 'Ver todo';
@override String get checkingNetwork => 'Comprobando red...';
@override String get refreshingServers => 'Actualizando servidores...';
@override String get loadingServers => 'Cargando servidores...';
@override String get connectingToServers => 'Conectando a servidores...';
@override String get startingOfflineMode => 'Iniciando modo sin conexión...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsEs implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse';
@override String get showUnwatchedCount => 'Mostrar conteo de no vistos';
@override String get showUnwatchedCountDescription => 'Mostrar el conteo de episodios no vistos en series y temporadas';
@override String get hideSpoilers => 'Ocultar spoilers de episodios no vistos';
@override String get hideSpoilersDescription => 'Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto';
@override String get playerBackend => 'Reproductor';
@override String get exoPlayer => 'ExoPlayer (Recomendado)';
@override String get exoPlayerDescription => 'Reproductor nativo de Android con mejor soporte de hardware';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuEs implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Ir a la temporada';
@override String get shufflePlay => 'Reproducción Aleatoria';
@override String get fileInfo => 'Información del Archivo';
@override String get confirmDelete => '¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?';
@override String get deleteMultipleWarning => 'Es posible que se eliminen varios elementos.';
@override String get deleteFromServer => 'Eliminar del servidor';
@override String get confirmDelete => 'Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.';
@override String get deleteMultipleWarning => 'Esto incluye todos los episodios y sus archivos.';
@override String get mediaDeletedSuccessfully => 'Elemento multimedia eliminado con éxito';
@override String get mediaFailedToDelete => 'Error al eliminar el elemento multimedia';
@override String get rate => 'Calificar';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsEs implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Salida de audio';
@override String get performanceOverlay => 'Indicador de rendimiento';
@override String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get recent => 'Recientes';
@override String get scan => 'Escanear';
@override String get manual => 'Manual';
@override String get recentConnections => 'Conexiones recientes';
@override String get quickReconnect => 'Reconectar rápidamente con dispositivos emparejados anteriormente';
@override String get pairWithDesktop => 'Emparejar con escritorio';
@override String get enterSessionDetails => 'Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.';
@override String cameraError({required Object error}) => 'No se pudo iniciar la cámara: ${error}';
@override String get scanInstruction => 'Apunta tu cámara al código QR que aparece en tu escritorio';
@override String get noRecentConnections => 'No hay conexiones recientes';
@override String get connectUsingManual => 'Conéctate a un dispositivo usando la entrada manual para empezar';
@override String get invalidQrCode => 'Formato de código QR no válido';
@override String get removeRecentConnection => 'Eliminar conexión reciente';
@override String removeConfirm({required Object name}) => '¿Eliminar "${name}" de las conexiones recientes?';
@override String get validationHostRequired => 'Por favor, introduce la dirección del host';
@override String get validationHostFormat => 'El formato debe ser IP:puerto (ej., 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Por favor, introduce un ID de sesión';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingEs implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.';
@override String get sessionNotFound => 'No se encontró la sesión. Verifica tus credenciales.';
@override String failedToConnect({required Object error}) => 'Error al conectar: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Error al cargar sesiones recientes: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsEs {
'common.dontAskAgain' => 'No volver a preguntar',
'common.exit' => 'Salir',
'common.viewAll' => 'Ver todo',
'common.checkingNetwork' => 'Comprobando red...',
'common.refreshingServers' => 'Actualizando servidores...',
'common.loadingServers' => 'Cargando servidores...',
'common.connectingToServers' => 'Conectando a servidores...',
'common.startingOfflineMode' => 'Iniciando modo sin conexión...',
'screens.licenses' => 'Licencias',
'screens.switchProfile' => 'Cambiar Perfil',
'screens.subtitleStyling' => 'Estilo de Subtítulos',
@@ -1401,6 +1407,8 @@ extension on TranslationsEs {
'settings.alwaysKeepSidebarOpenDescription' => 'La barra lateral permanece expandida y el área de contenido se ajusta para adaptarse',
'settings.showUnwatchedCount' => 'Mostrar conteo de no vistos',
'settings.showUnwatchedCountDescription' => 'Mostrar el conteo de episodios no vistos en series y temporadas',
'settings.hideSpoilers' => 'Ocultar spoilers de episodios no vistos',
'settings.hideSpoilersDescription' => 'Difuminar miniaturas y ocultar descripciones de episodios que aún no has visto',
'settings.playerBackend' => 'Reproductor',
'settings.exoPlayer' => 'ExoPlayer (Recomendado)',
'settings.exoPlayerDescription' => 'Reproductor nativo de Android con mejor soporte de hardware',
@@ -1538,8 +1546,9 @@ extension on TranslationsEs {
'mediaMenu.goToSeason' => 'Ir a la temporada',
'mediaMenu.shufflePlay' => 'Reproducción Aleatoria',
'mediaMenu.fileInfo' => 'Información del Archivo',
'mediaMenu.confirmDelete' => '¿Estás seguro de que quieres eliminar este elemento de tu sistema de archivos?',
'mediaMenu.deleteMultipleWarning' => 'Es posible que se eliminen varios elementos.',
'mediaMenu.deleteFromServer' => 'Eliminar del servidor',
'mediaMenu.confirmDelete' => 'Esto eliminará permanentemente este contenido y sus archivos de tu servidor. Esta acción no se puede deshacer.',
'mediaMenu.deleteMultipleWarning' => 'Esto incluye todos los episodios y sus archivos.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimedia eliminado con éxito',
'mediaMenu.mediaFailedToDelete' => 'Error al eliminar el elemento multimedia',
'mediaMenu.rate' => 'Calificar',
@@ -1949,11 +1958,8 @@ extension on TranslationsEs {
'companionRemote.session.copyToClipboard' => 'Copiar al portapapeles',
'companionRemote.session.newSession' => 'Nueva sesión',
'companionRemote.session.minimize' => 'Minimizar',
'companionRemote.pairing.recent' => 'Recientes',
'companionRemote.pairing.scan' => 'Escanear',
'companionRemote.pairing.manual' => 'Manual',
'companionRemote.pairing.recentConnections' => 'Conexiones recientes',
'companionRemote.pairing.quickReconnect' => 'Reconectar rápidamente con dispositivos emparejados anteriormente',
'companionRemote.pairing.pairWithDesktop' => 'Emparejar con escritorio',
'companionRemote.pairing.enterSessionDetails' => 'Introduce los datos de la sesión que aparecen en tu dispositivo de escritorio',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsEs {
'companionRemote.pairing.cameraPermissionRequired' => 'Se necesita permiso de cámara para escanear códigos QR.\nPor favor, concede acceso a la cámara en los ajustes de tu dispositivo.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'No se pudo iniciar la cámara: ${error}',
'companionRemote.pairing.scanInstruction' => 'Apunta tu cámara al código QR que aparece en tu escritorio',
'companionRemote.pairing.noRecentConnections' => 'No hay conexiones recientes',
'companionRemote.pairing.connectUsingManual' => 'Conéctate a un dispositivo usando la entrada manual para empezar',
'companionRemote.pairing.invalidQrCode' => 'Formato de código QR no válido',
'companionRemote.pairing.removeRecentConnection' => 'Eliminar conexión reciente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '¿Eliminar "${name}" de las conexiones recientes?',
'companionRemote.pairing.validationHostRequired' => 'Por favor, introduce la dirección del host',
'companionRemote.pairing.validationHostFormat' => 'El formato debe ser IP:puerto (ej., 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Por favor, introduce un ID de sesión',
@@ -1981,7 +1983,6 @@ extension on TranslationsEs {
'companionRemote.pairing.connectionTimedOut' => 'Tiempo de conexión agotado. Verifica el ID de sesión y el PIN.',
'companionRemote.pairing.sessionNotFound' => 'No se encontró la sesión. Verifica tus credenciales.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Error al conectar: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Error al cargar sesiones recientes: ${error}',
'companionRemote.remote.disconnectConfirm' => '¿Quieres desconectarte de la sesión remota?',
'companionRemote.remote.reconnecting' => 'Reconectando...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Intento ${current} de 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsEs {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Salida de audio',
'videoSettings.performanceOverlay' => 'Indicador de rendimiento',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'Reproductor externo',
'externalPlayer.useExternalPlayer' => 'Usar reproductor externo',
'externalPlayer.useExternalPlayerDescription' => 'Abrir vídeos en una app externa en lugar del reproductor integrado',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonFr implements TranslationsCommonEn {
@override String get dontAskAgain => 'Ne plus demander';
@override String get exit => 'Quitter';
@override String get viewAll => 'Tout afficher';
@override String get checkingNetwork => 'Vérification du réseau...';
@override String get refreshingServers => 'Actualisation des serveurs...';
@override String get loadingServers => 'Chargement des serveurs...';
@override String get connectingToServers => 'Connexion aux serveurs...';
@override String get startingOfflineMode => 'Démarrage en mode hors-ligne...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsFr implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barre latérale reste étendue et la zone de contenu s\'adapte';
@override String get showUnwatchedCount => 'Afficher le nombre non visionné';
@override String get showUnwatchedCountDescription => 'Afficher le nombre d\'épisodes non visionnés pour les séries et saisons';
@override String get hideSpoilers => 'Masquer les spoilers des épisodes non vus';
@override String get hideSpoilersDescription => 'Flouter les miniatures et masquer les descriptions des épisodes que vous n\'avez pas encore regardés';
@override String get playerBackend => 'Moteur de lecture';
@override String get exoPlayer => 'ExoPlayer (Recommandé)';
@override String get exoPlayerDescription => 'Lecteur natif Android avec meilleur support matériel';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuFr implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Aller à la saison';
@override String get shufflePlay => 'Lecture aléatoire';
@override String get fileInfo => 'Informations sur le fichier';
@override String get confirmDelete => 'Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?';
@override String get deleteMultipleWarning => 'Plusieurs éléments peuvent être supprimés.';
@override String get deleteFromServer => 'Supprimer du serveur';
@override String get confirmDelete => 'Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.';
@override String get deleteMultipleWarning => 'Cela inclut tous les épisodes et leurs fichiers.';
@override String get mediaDeletedSuccessfully => 'Élément média supprimé avec succès';
@override String get mediaFailedToDelete => 'Échec de la suppression de l\'élément média';
@override String get rate => 'Noter';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsFr implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Sortie audio';
@override String get performanceOverlay => 'Superposition de performance';
@override String get audioPassthrough => 'Audio Pass-Through';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
final TranslationsFr _root; // ignore: unused_field
// Translations
@override String get recent => 'Récents';
@override String get scan => 'Scanner';
@override String get manual => 'Manuel';
@override String get recentConnections => 'Connexions récentes';
@override String get quickReconnect => 'Reconnexion rapide aux appareils précédemment jumelés';
@override String get pairWithDesktop => 'Jumeler avec un bureau';
@override String get enterSessionDetails => 'Saisissez les détails de la session affichés sur votre appareil de bureau';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'L\'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l\'accès à la caméra dans les paramètres de votre appareil.';
@override String cameraError({required Object error}) => 'Impossible de démarrer la caméra : ${error}';
@override String get scanInstruction => 'Pointez votre caméra vers le QR code affiché sur votre bureau';
@override String get noRecentConnections => 'Aucune connexion récente';
@override String get connectUsingManual => 'Connectez-vous à un appareil via la saisie manuelle pour commencer';
@override String get invalidQrCode => 'Format de QR code invalide';
@override String get removeRecentConnection => 'Supprimer la connexion récente';
@override String removeConfirm({required Object name}) => 'Supprimer "${name}" des connexions récentes ?';
@override String get validationHostRequired => 'Veuillez saisir l\'adresse de l\'hôte';
@override String get validationHostFormat => 'Le format doit être IP:port (ex : 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Veuillez saisir un ID de session';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingFr implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Délai de connexion expiré. Veuillez vérifier l\'ID de session et le PIN.';
@override String get sessionNotFound => 'Session introuvable. Veuillez vérifier vos identifiants.';
@override String failedToConnect({required Object error}) => 'Échec de la connexion : ${error}';
@override String failedToLoadRecent({required Object error}) => 'Échec du chargement des sessions récentes : ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsFr {
'common.dontAskAgain' => 'Ne plus demander',
'common.exit' => 'Quitter',
'common.viewAll' => 'Tout afficher',
'common.checkingNetwork' => 'Vérification du réseau...',
'common.refreshingServers' => 'Actualisation des serveurs...',
'common.loadingServers' => 'Chargement des serveurs...',
'common.connectingToServers' => 'Connexion aux serveurs...',
'common.startingOfflineMode' => 'Démarrage en mode hors-ligne...',
'screens.licenses' => 'Licenses',
'screens.switchProfile' => 'Changer de profil',
'screens.subtitleStyling' => 'Configuration des sous-titres',
@@ -1401,6 +1407,8 @@ extension on TranslationsFr {
'settings.alwaysKeepSidebarOpenDescription' => 'La barre latérale reste étendue et la zone de contenu s\'adapte',
'settings.showUnwatchedCount' => 'Afficher le nombre non visionné',
'settings.showUnwatchedCountDescription' => 'Afficher le nombre d\'épisodes non visionnés pour les séries et saisons',
'settings.hideSpoilers' => 'Masquer les spoilers des épisodes non vus',
'settings.hideSpoilersDescription' => 'Flouter les miniatures et masquer les descriptions des épisodes que vous n\'avez pas encore regardés',
'settings.playerBackend' => 'Moteur de lecture',
'settings.exoPlayer' => 'ExoPlayer (Recommandé)',
'settings.exoPlayerDescription' => 'Lecteur natif Android avec meilleur support matériel',
@@ -1538,8 +1546,9 @@ extension on TranslationsFr {
'mediaMenu.goToSeason' => 'Aller à la saison',
'mediaMenu.shufflePlay' => 'Lecture aléatoire',
'mediaMenu.fileInfo' => 'Informations sur le fichier',
'mediaMenu.confirmDelete' => 'Êtes-vous sûr de vouloir supprimer cet élément de votre système de fichiers?',
'mediaMenu.deleteMultipleWarning' => 'Plusieurs éléments peuvent être supprimés.',
'mediaMenu.deleteFromServer' => 'Supprimer du serveur',
'mediaMenu.confirmDelete' => 'Cela supprimera définitivement ce média et ses fichiers de votre serveur. Cette action est irréversible.',
'mediaMenu.deleteMultipleWarning' => 'Cela inclut tous les épisodes et leurs fichiers.',
'mediaMenu.mediaDeletedSuccessfully' => 'Élément média supprimé avec succès',
'mediaMenu.mediaFailedToDelete' => 'Échec de la suppression de l\'élément média',
'mediaMenu.rate' => 'Noter',
@@ -1949,11 +1958,8 @@ extension on TranslationsFr {
'companionRemote.session.copyToClipboard' => 'Copier dans le presse-papiers',
'companionRemote.session.newSession' => 'Nouvelle session',
'companionRemote.session.minimize' => 'Réduire',
'companionRemote.pairing.recent' => 'Récents',
'companionRemote.pairing.scan' => 'Scanner',
'companionRemote.pairing.manual' => 'Manuel',
'companionRemote.pairing.recentConnections' => 'Connexions récentes',
'companionRemote.pairing.quickReconnect' => 'Reconnexion rapide aux appareils précédemment jumelés',
'companionRemote.pairing.pairWithDesktop' => 'Jumeler avec un bureau',
'companionRemote.pairing.enterSessionDetails' => 'Saisissez les détails de la session affichés sur votre appareil de bureau',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsFr {
'companionRemote.pairing.cameraPermissionRequired' => 'L\'autorisation de la caméra est requise pour scanner les QR codes.\nVeuillez accorder l\'accès à la caméra dans les paramètres de votre appareil.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Impossible de démarrer la caméra : ${error}',
'companionRemote.pairing.scanInstruction' => 'Pointez votre caméra vers le QR code affiché sur votre bureau',
'companionRemote.pairing.noRecentConnections' => 'Aucune connexion récente',
'companionRemote.pairing.connectUsingManual' => 'Connectez-vous à un appareil via la saisie manuelle pour commencer',
'companionRemote.pairing.invalidQrCode' => 'Format de QR code invalide',
'companionRemote.pairing.removeRecentConnection' => 'Supprimer la connexion récente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Supprimer "${name}" des connexions récentes ?',
'companionRemote.pairing.validationHostRequired' => 'Veuillez saisir l\'adresse de l\'hôte',
'companionRemote.pairing.validationHostFormat' => 'Le format doit être IP:port (ex : 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Veuillez saisir un ID de session',
@@ -1981,7 +1983,6 @@ extension on TranslationsFr {
'companionRemote.pairing.connectionTimedOut' => 'Délai de connexion expiré. Veuillez vérifier l\'ID de session et le PIN.',
'companionRemote.pairing.sessionNotFound' => 'Session introuvable. Veuillez vérifier vos identifiants.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Échec de la connexion : ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Échec du chargement des sessions récentes : ${error}',
'companionRemote.remote.disconnectConfirm' => 'Voulez-vous vous déconnecter de la session distante ?',
'companionRemote.remote.reconnecting' => 'Reconnexion...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentative ${current} sur 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsFr {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Sortie audio',
'videoSettings.performanceOverlay' => 'Superposition de performance',
'videoSettings.audioPassthrough' => 'Audio Pass-Through',
'externalPlayer.title' => 'Lecteur externe',
'externalPlayer.useExternalPlayer' => 'Utiliser un lecteur externe',
'externalPlayer.useExternalPlayerDescription' => 'Ouvrir les vidéos dans une application externe au lieu du lecteur intégré',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonIt implements TranslationsCommonEn {
@override String get dontAskAgain => 'Non chiedere più';
@override String get exit => 'Esci';
@override String get viewAll => 'Mostra tutto';
@override String get checkingNetwork => 'Verifica rete...';
@override String get refreshingServers => 'Aggiornamento server...';
@override String get loadingServers => 'Caricamento server...';
@override String get connectingToServers => 'Connessione ai server...';
@override String get startingOfflineMode => 'Avvio modalità offline...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsIt implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'La barra laterale rimane espansa e l\'area del contenuto si adatta';
@override String get showUnwatchedCount => 'Mostra conteggio non visti';
@override String get showUnwatchedCountDescription => 'Mostra il numero di episodi non visti per serie e stagioni';
@override String get hideSpoilers => 'Nascondi spoiler per episodi non visti';
@override String get hideSpoilersDescription => 'Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato';
@override String get playerBackend => 'Motore di riproduzione';
@override String get exoPlayer => 'ExoPlayer (Consigliato)';
@override String get exoPlayerDescription => 'Lettore nativo Android con migliore supporto hardware';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuIt implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Vai alla stagione';
@override String get shufflePlay => 'Riproduzione casuale';
@override String get fileInfo => 'Info sul file';
@override String get confirmDelete => 'Sei sicuro di voler eliminare questo elemento dal tuo filesystem?';
@override String get deleteMultipleWarning => 'Potrebbero essere eliminati più elementi.';
@override String get deleteFromServer => 'Elimina dal server';
@override String get confirmDelete => 'Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.';
@override String get deleteMultipleWarning => 'Questo include tutti gli episodi e i loro file.';
@override String get mediaDeletedSuccessfully => 'Elemento multimediale eliminato con successo';
@override String get mediaFailedToDelete => 'Impossibile eliminare l\'elemento multimediale';
@override String get rate => 'Valuta';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsIt implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Uscita audio';
@override String get performanceOverlay => 'Overlay prestazioni';
@override String get audioPassthrough => 'Audio Passthrough';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
final TranslationsIt _root; // ignore: unused_field
// Translations
@override String get recent => 'Recenti';
@override String get scan => 'Scansiona';
@override String get manual => 'Manuale';
@override String get recentConnections => 'Connessioni recenti';
@override String get quickReconnect => 'Riconnettiti rapidamente ai dispositivi associati in precedenza';
@override String get pairWithDesktop => 'Associa con desktop';
@override String get enterSessionDetails => 'Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'L\'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l\'accesso alla fotocamera nelle impostazioni del dispositivo.';
@override String cameraError({required Object error}) => 'Impossibile avviare la fotocamera: ${error}';
@override String get scanInstruction => 'Punta la fotocamera verso il QR code mostrato sul tuo desktop';
@override String get noRecentConnections => 'Nessuna connessione recente';
@override String get connectUsingManual => 'Connettiti a un dispositivo tramite inserimento manuale per iniziare';
@override String get invalidQrCode => 'Formato QR code non valido';
@override String get removeRecentConnection => 'Rimuovi connessione recente';
@override String removeConfirm({required Object name}) => 'Rimuovere "${name}" dalle connessioni recenti?';
@override String get validationHostRequired => 'Inserisci l\'indirizzo host';
@override String get validationHostFormat => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Inserisci un ID sessione';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingIt implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Connessione scaduta. Verifica l\'ID sessione e il PIN.';
@override String get sessionNotFound => 'Sessione non trovata. Verifica le tue credenziali.';
@override String failedToConnect({required Object error}) => 'Connessione fallita: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Impossibile caricare le sessioni recenti: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsIt {
'common.dontAskAgain' => 'Non chiedere più',
'common.exit' => 'Esci',
'common.viewAll' => 'Mostra tutto',
'common.checkingNetwork' => 'Verifica rete...',
'common.refreshingServers' => 'Aggiornamento server...',
'common.loadingServers' => 'Caricamento server...',
'common.connectingToServers' => 'Connessione ai server...',
'common.startingOfflineMode' => 'Avvio modalità offline...',
'screens.licenses' => 'Licenze',
'screens.switchProfile' => 'Cambia profilo',
'screens.subtitleStyling' => 'Stile sottotitoli',
@@ -1401,6 +1407,8 @@ extension on TranslationsIt {
'settings.alwaysKeepSidebarOpenDescription' => 'La barra laterale rimane espansa e l\'area del contenuto si adatta',
'settings.showUnwatchedCount' => 'Mostra conteggio non visti',
'settings.showUnwatchedCountDescription' => 'Mostra il numero di episodi non visti per serie e stagioni',
'settings.hideSpoilers' => 'Nascondi spoiler per episodi non visti',
'settings.hideSpoilersDescription' => 'Sfoca le miniature e nascondi le descrizioni degli episodi che non hai ancora guardato',
'settings.playerBackend' => 'Motore di riproduzione',
'settings.exoPlayer' => 'ExoPlayer (Consigliato)',
'settings.exoPlayerDescription' => 'Lettore nativo Android con migliore supporto hardware',
@@ -1538,8 +1546,9 @@ extension on TranslationsIt {
'mediaMenu.goToSeason' => 'Vai alla stagione',
'mediaMenu.shufflePlay' => 'Riproduzione casuale',
'mediaMenu.fileInfo' => 'Info sul file',
'mediaMenu.confirmDelete' => 'Sei sicuro di voler eliminare questo elemento dal tuo filesystem?',
'mediaMenu.deleteMultipleWarning' => 'Potrebbero essere eliminati più elementi.',
'mediaMenu.deleteFromServer' => 'Elimina dal server',
'mediaMenu.confirmDelete' => 'Questo eliminerà permanentemente questo contenuto e i suoi file dal tuo server. Questa azione non può essere annullata.',
'mediaMenu.deleteMultipleWarning' => 'Questo include tutti gli episodi e i loro file.',
'mediaMenu.mediaDeletedSuccessfully' => 'Elemento multimediale eliminato con successo',
'mediaMenu.mediaFailedToDelete' => 'Impossibile eliminare l\'elemento multimediale',
'mediaMenu.rate' => 'Valuta',
@@ -1949,11 +1958,8 @@ extension on TranslationsIt {
'companionRemote.session.copyToClipboard' => 'Copia negli appunti',
'companionRemote.session.newSession' => 'Nuova sessione',
'companionRemote.session.minimize' => 'Riduci',
'companionRemote.pairing.recent' => 'Recenti',
'companionRemote.pairing.scan' => 'Scansiona',
'companionRemote.pairing.manual' => 'Manuale',
'companionRemote.pairing.recentConnections' => 'Connessioni recenti',
'companionRemote.pairing.quickReconnect' => 'Riconnettiti rapidamente ai dispositivi associati in precedenza',
'companionRemote.pairing.pairWithDesktop' => 'Associa con desktop',
'companionRemote.pairing.enterSessionDetails' => 'Inserisci i dettagli della sessione mostrati sul tuo dispositivo desktop',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsIt {
'companionRemote.pairing.cameraPermissionRequired' => 'L\'autorizzazione della fotocamera è necessaria per scansionare i QR code.\nConcedi l\'accesso alla fotocamera nelle impostazioni del dispositivo.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Impossibile avviare la fotocamera: ${error}',
'companionRemote.pairing.scanInstruction' => 'Punta la fotocamera verso il QR code mostrato sul tuo desktop',
'companionRemote.pairing.noRecentConnections' => 'Nessuna connessione recente',
'companionRemote.pairing.connectUsingManual' => 'Connettiti a un dispositivo tramite inserimento manuale per iniziare',
'companionRemote.pairing.invalidQrCode' => 'Formato QR code non valido',
'companionRemote.pairing.removeRecentConnection' => 'Rimuovi connessione recente',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Rimuovere "${name}" dalle connessioni recenti?',
'companionRemote.pairing.validationHostRequired' => 'Inserisci l\'indirizzo host',
'companionRemote.pairing.validationHostFormat' => 'Il formato deve essere IP:porta (es. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Inserisci un ID sessione',
@@ -1981,7 +1983,6 @@ extension on TranslationsIt {
'companionRemote.pairing.connectionTimedOut' => 'Connessione scaduta. Verifica l\'ID sessione e il PIN.',
'companionRemote.pairing.sessionNotFound' => 'Sessione non trovata. Verifica le tue credenziali.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Connessione fallita: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Impossibile caricare le sessioni recenti: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vuoi disconnetterti dalla sessione remota?',
'companionRemote.remote.reconnecting' => 'Riconnessione...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Tentativo ${current} di 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsIt {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Uscita audio',
'videoSettings.performanceOverlay' => 'Overlay prestazioni',
'videoSettings.audioPassthrough' => 'Audio Passthrough',
'externalPlayer.title' => 'Lettore esterno',
'externalPlayer.useExternalPlayer' => 'Usa lettore esterno',
'externalPlayer.useExternalPlayerDescription' => 'Apri i video in un\'app esterna invece del lettore integrato',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonKo implements TranslationsCommonEn {
@override String get dontAskAgain => '다시 묻지 않기';
@override String get exit => '종료';
@override String get viewAll => '모두 보기';
@override String get checkingNetwork => '네트워크 확인 중...';
@override String get refreshingServers => '서버 새로고침 중...';
@override String get loadingServers => '서버 로딩 중...';
@override String get connectingToServers => '서버 연결 중...';
@override String get startingOfflineMode => '오프라인 모드 시작 중...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsKo implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다';
@override String get showUnwatchedCount => '미시청 수 표시';
@override String get showUnwatchedCountDescription => '시리즈 및 시즌에 미시청 에피소드 수 표시';
@override String get hideSpoilers => '미시청 에피소드 스포일러 숨기기';
@override String get hideSpoilersDescription => '아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다';
@override String get playerBackend => '플레이어 백엔드';
@override String get exoPlayer => 'ExoPlayer (권장)';
@override String get exoPlayerDescription => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuKo implements TranslationsMediaMenuEn {
@override String get goToSeason => '시즌으로 이동';
@override String get shufflePlay => '무작위 재생';
@override String get fileInfo => '파일 정보';
@override String get confirmDelete => '파일 시스템에서 이 항목을 삭제하시겠습니까?';
@override String get deleteMultipleWarning => '여러 항목이 삭제될습니다.';
@override String get deleteFromServer => '서버에서 삭제';
@override String get confirmDelete => '이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.';
@override String get deleteMultipleWarning => '모든 에피소드와 파일이 포함됩니다.';
@override String get mediaDeletedSuccessfully => '미디어 항목이 성공적으로 삭제되었습니다';
@override String get mediaFailedToDelete => '미디어 항목 삭제 실패';
@override String get rate => '평가';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => '오디오 출력';
@override String get performanceOverlay => '성능 오버레이';
@override String get audioPassthrough => '오디오 패스스루';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
final TranslationsKo _root; // ignore: unused_field
// Translations
@override String get recent => '최근';
@override String get scan => '스캔';
@override String get manual => '수동';
@override String get recentConnections => '최근 연결';
@override String get quickReconnect => '이전에 페어링한 기기에 빠르게 재연결';
@override String get pairWithDesktop => '데스크톱과 페어링';
@override String get enterSessionDetails => '데스크톱 기기에 표시된 세션 정보를 입력하세요';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.';
@override String cameraError({required Object error}) => '카메라를 시작할 수 없습니다: ${error}';
@override String get scanInstruction => '데스크톱에 표시된 QR 코드에 카메라를 향하세요';
@override String get noRecentConnections => '최근 연결 없음';
@override String get connectUsingManual => '수동 입력으로 기기에 연결하여 시작하세요';
@override String get invalidQrCode => '유효하지 않은 QR 코드 형식';
@override String get removeRecentConnection => '최근 연결 삭제';
@override String removeConfirm({required Object name}) => '"${name}"을(를) 최근 연결에서 삭제하시겠습니까?';
@override String get validationHostRequired => '호스트 주소를 입력하세요';
@override String get validationHostFormat => 'IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)';
@override String get validationSessionIdRequired => '세션 ID를 입력하세요';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingKo implements TranslationsCompanionRemo
@override String get connectionTimedOut => '연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.';
@override String get sessionNotFound => '세션을 찾을 수 없습니다. 자격 증명을 확인하세요.';
@override String failedToConnect({required Object error}) => '연결 실패: ${error}';
@override String failedToLoadRecent({required Object error}) => '최근 세션 로드 실패: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsKo {
'common.dontAskAgain' => '다시 묻지 않기',
'common.exit' => '종료',
'common.viewAll' => '모두 보기',
'common.checkingNetwork' => '네트워크 확인 중...',
'common.refreshingServers' => '서버 새로고침 중...',
'common.loadingServers' => '서버 로딩 중...',
'common.connectingToServers' => '서버 연결 중...',
'common.startingOfflineMode' => '오프라인 모드 시작 중...',
'screens.licenses' => '라이선스',
'screens.switchProfile' => '프로필 전환',
'screens.subtitleStyling' => '자막 스타일 설정',
@@ -1401,6 +1407,8 @@ extension on TranslationsKo {
'settings.alwaysKeepSidebarOpenDescription' => '사이드바가 확장된 상태로 유지되고 콘텐츠 영역이 맞춰집니다',
'settings.showUnwatchedCount' => '미시청 수 표시',
'settings.showUnwatchedCountDescription' => '시리즈 및 시즌에 미시청 에피소드 수 표시',
'settings.hideSpoilers' => '미시청 에피소드 스포일러 숨기기',
'settings.hideSpoilersDescription' => '아직 시청하지 않은 에피소드의 썸네일을 흐리게 하고 설명을 숨깁니다',
'settings.playerBackend' => '플레이어 백엔드',
'settings.exoPlayer' => 'ExoPlayer (권장)',
'settings.exoPlayerDescription' => '더 나은 하드웨어 지원을 제공하는 Android 네이티브 플레이어',
@@ -1538,8 +1546,9 @@ extension on TranslationsKo {
'mediaMenu.goToSeason' => '시즌으로 이동',
'mediaMenu.shufflePlay' => '무작위 재생',
'mediaMenu.fileInfo' => '파일 정보',
'mediaMenu.confirmDelete' => '파일 시스템에서 이 항목을 삭제하시겠습니까?',
'mediaMenu.deleteMultipleWarning' => '여러 항목이 삭제될습니다.',
'mediaMenu.deleteFromServer' => '서버에서 삭제',
'mediaMenu.confirmDelete' => '이 미디어와 파일이 서버에서 영구적으로 삭제됩니다. 이 작업은 취소할습니다.',
'mediaMenu.deleteMultipleWarning' => '모든 에피소드와 파일이 포함됩니다.',
'mediaMenu.mediaDeletedSuccessfully' => '미디어 항목이 성공적으로 삭제되었습니다',
'mediaMenu.mediaFailedToDelete' => '미디어 항목 삭제 실패',
'mediaMenu.rate' => '평가',
@@ -1949,11 +1958,8 @@ extension on TranslationsKo {
'companionRemote.session.copyToClipboard' => '클립보드에 복사',
'companionRemote.session.newSession' => '새 세션',
'companionRemote.session.minimize' => '최소화',
'companionRemote.pairing.recent' => '최근',
'companionRemote.pairing.scan' => '스캔',
'companionRemote.pairing.manual' => '수동',
'companionRemote.pairing.recentConnections' => '최근 연결',
'companionRemote.pairing.quickReconnect' => '이전에 페어링한 기기에 빠르게 재연결',
'companionRemote.pairing.pairWithDesktop' => '데스크톱과 페어링',
'companionRemote.pairing.enterSessionDetails' => '데스크톱 기기에 표시된 세션 정보를 입력하세요',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsKo {
'companionRemote.pairing.cameraPermissionRequired' => 'QR 코드를 스캔하려면 카메라 권한이 필요합니다.\n기기 설정에서 카메라 접근을 허용해 주세요.',
'companionRemote.pairing.cameraError' => ({required Object error}) => '카메라를 시작할 수 없습니다: ${error}',
'companionRemote.pairing.scanInstruction' => '데스크톱에 표시된 QR 코드에 카메라를 향하세요',
'companionRemote.pairing.noRecentConnections' => '최근 연결 없음',
'companionRemote.pairing.connectUsingManual' => '수동 입력으로 기기에 연결하여 시작하세요',
'companionRemote.pairing.invalidQrCode' => '유효하지 않은 QR 코드 형식',
'companionRemote.pairing.removeRecentConnection' => '최근 연결 삭제',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}"을(를) 최근 연결에서 삭제하시겠습니까?',
'companionRemote.pairing.validationHostRequired' => '호스트 주소를 입력하세요',
'companionRemote.pairing.validationHostFormat' => 'IP:포트 형식이어야 합니다 (예: 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => '세션 ID를 입력하세요',
@@ -1981,7 +1983,6 @@ extension on TranslationsKo {
'companionRemote.pairing.connectionTimedOut' => '연결 시간이 초과되었습니다. 세션 ID와 PIN을 확인하세요.',
'companionRemote.pairing.sessionNotFound' => '세션을 찾을 수 없습니다. 자격 증명을 확인하세요.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '연결 실패: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => '최근 세션 로드 실패: ${error}',
'companionRemote.remote.disconnectConfirm' => '원격 세션 연결을 해제하시겠습니까?',
'companionRemote.remote.reconnecting' => '재연결 중...',
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current}/5 시도 중',
@@ -2019,6 +2020,7 @@ extension on TranslationsKo {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '오디오 출력',
'videoSettings.performanceOverlay' => '성능 오버레이',
'videoSettings.audioPassthrough' => '오디오 패스스루',
'externalPlayer.title' => '외부 플레이어',
'externalPlayer.useExternalPlayer' => '외부 플레이어 사용',
'externalPlayer.useExternalPlayerDescription' => '내장 플레이어 대신 외부 앱에서 동영상 열기',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonNl implements TranslationsCommonEn {
@override String get dontAskAgain => 'Niet meer vragen';
@override String get exit => 'Afsluiten';
@override String get viewAll => 'Alles weergeven';
@override String get checkingNetwork => 'Netwerk controleren...';
@override String get refreshingServers => 'Servers vernieuwen...';
@override String get loadingServers => 'Servers laden...';
@override String get connectingToServers => 'Verbinden met servers...';
@override String get startingOfflineMode => 'Offlinemodus starten...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsNl implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan';
@override String get showUnwatchedCount => 'Aantal ongekeken tonen';
@override String get showUnwatchedCountDescription => 'Toon aantal ongekeken afleveringen bij series en seizoenen';
@override String get hideSpoilers => 'Spoilers voor ongekeken afleveringen verbergen';
@override String get hideSpoilersDescription => 'Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien';
@override String get playerBackend => 'Speler backend';
@override String get exoPlayer => 'ExoPlayer (Aanbevolen)';
@override String get exoPlayerDescription => 'Android-native speler met betere hardware-ondersteuning';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuNl implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Ga naar seizoen';
@override String get shufflePlay => 'Willekeurig afspelen';
@override String get fileInfo => 'Bestand info';
@override String get confirmDelete => 'Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?';
@override String get deleteMultipleWarning => 'Meerdere items kunnen worden verwijderd.';
@override String get deleteFromServer => 'Verwijderen van server';
@override String get confirmDelete => 'Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.';
@override String get deleteMultipleWarning => 'Dit omvat alle afleveringen en hun bestanden.';
@override String get mediaDeletedSuccessfully => 'Media-item succesvol verwijderd';
@override String get mediaFailedToDelete => 'Verwijderen van media-item mislukt';
@override String get rate => 'Beoordelen';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsNl implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Audio-uitvoer';
@override String get performanceOverlay => 'Prestatie-overlay';
@override String get audioPassthrough => 'Audio-doorvoer';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
final TranslationsNl _root; // ignore: unused_field
// Translations
@override String get recent => 'Recent';
@override String get scan => 'Scannen';
@override String get manual => 'Handmatig';
@override String get recentConnections => 'Recente verbindingen';
@override String get quickReconnect => 'Snel opnieuw verbinden met eerder gekoppelde apparaten';
@override String get pairWithDesktop => 'Koppelen met desktop';
@override String get enterSessionDetails => 'Voer de sessiegegevens in die op je desktop-apparaat worden getoond';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.';
@override String cameraError({required Object error}) => 'Kan camera niet starten: ${error}';
@override String get scanInstruction => 'Richt je camera op de QR-code die op je desktop wordt getoond';
@override String get noRecentConnections => 'Geen recente verbindingen';
@override String get connectUsingManual => 'Verbind met een apparaat via Handmatige invoer om te beginnen';
@override String get invalidQrCode => 'Ongeldig QR-codeformaat';
@override String get removeRecentConnection => 'Recente verbinding verwijderen';
@override String removeConfirm({required Object name}) => '"${name}" verwijderen uit recente verbindingen?';
@override String get validationHostRequired => 'Voer een hostadres in';
@override String get validationHostFormat => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Voer een sessie-ID in';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingNl implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Verbinding verlopen. Controleer de sessie-ID en PIN.';
@override String get sessionNotFound => 'Kan de sessie niet vinden. Controleer je gegevens.';
@override String failedToConnect({required Object error}) => 'Verbinden mislukt: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Kan recente sessies niet laden: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsNl {
'common.dontAskAgain' => 'Niet meer vragen',
'common.exit' => 'Afsluiten',
'common.viewAll' => 'Alles weergeven',
'common.checkingNetwork' => 'Netwerk controleren...',
'common.refreshingServers' => 'Servers vernieuwen...',
'common.loadingServers' => 'Servers laden...',
'common.connectingToServers' => 'Verbinden met servers...',
'common.startingOfflineMode' => 'Offlinemodus starten...',
'screens.licenses' => 'Licenties',
'screens.switchProfile' => 'Wissel van profiel',
'screens.subtitleStyling' => 'Ondertitel opmaak',
@@ -1401,6 +1407,8 @@ extension on TranslationsNl {
'settings.alwaysKeepSidebarOpenDescription' => 'Zijbalk blijft uitgevouwen en inhoudsgebied past zich aan',
'settings.showUnwatchedCount' => 'Aantal ongekeken tonen',
'settings.showUnwatchedCountDescription' => 'Toon aantal ongekeken afleveringen bij series en seizoenen',
'settings.hideSpoilers' => 'Spoilers voor ongekeken afleveringen verbergen',
'settings.hideSpoilersDescription' => 'Miniaturen vervagen en beschrijvingen verbergen voor afleveringen die je nog niet hebt gezien',
'settings.playerBackend' => 'Speler backend',
'settings.exoPlayer' => 'ExoPlayer (Aanbevolen)',
'settings.exoPlayerDescription' => 'Android-native speler met betere hardware-ondersteuning',
@@ -1538,8 +1546,9 @@ extension on TranslationsNl {
'mediaMenu.goToSeason' => 'Ga naar seizoen',
'mediaMenu.shufflePlay' => 'Willekeurig afspelen',
'mediaMenu.fileInfo' => 'Bestand info',
'mediaMenu.confirmDelete' => 'Weet je zeker dat je dit item van je bestandssysteem wilt verwijderen?',
'mediaMenu.deleteMultipleWarning' => 'Meerdere items kunnen worden verwijderd.',
'mediaMenu.deleteFromServer' => 'Verwijderen van server',
'mediaMenu.confirmDelete' => 'Dit zal deze media en de bijbehorende bestanden permanent van je server verwijderen. Dit kan niet ongedaan worden gemaakt.',
'mediaMenu.deleteMultipleWarning' => 'Dit omvat alle afleveringen en hun bestanden.',
'mediaMenu.mediaDeletedSuccessfully' => 'Media-item succesvol verwijderd',
'mediaMenu.mediaFailedToDelete' => 'Verwijderen van media-item mislukt',
'mediaMenu.rate' => 'Beoordelen',
@@ -1949,11 +1958,8 @@ extension on TranslationsNl {
'companionRemote.session.copyToClipboard' => 'Kopieer naar klembord',
'companionRemote.session.newSession' => 'Nieuwe sessie',
'companionRemote.session.minimize' => 'Minimaliseren',
'companionRemote.pairing.recent' => 'Recent',
'companionRemote.pairing.scan' => 'Scannen',
'companionRemote.pairing.manual' => 'Handmatig',
'companionRemote.pairing.recentConnections' => 'Recente verbindingen',
'companionRemote.pairing.quickReconnect' => 'Snel opnieuw verbinden met eerder gekoppelde apparaten',
'companionRemote.pairing.pairWithDesktop' => 'Koppelen met desktop',
'companionRemote.pairing.enterSessionDetails' => 'Voer de sessiegegevens in die op je desktop-apparaat worden getoond',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsNl {
'companionRemote.pairing.cameraPermissionRequired' => 'Cameratoestemming is vereist om QR-codes te scannen.\nGeef cameratoegang in je apparaatinstellingen.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kan camera niet starten: ${error}',
'companionRemote.pairing.scanInstruction' => 'Richt je camera op de QR-code die op je desktop wordt getoond',
'companionRemote.pairing.noRecentConnections' => 'Geen recente verbindingen',
'companionRemote.pairing.connectUsingManual' => 'Verbind met een apparaat via Handmatige invoer om te beginnen',
'companionRemote.pairing.invalidQrCode' => 'Ongeldig QR-codeformaat',
'companionRemote.pairing.removeRecentConnection' => 'Recente verbinding verwijderen',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '"${name}" verwijderen uit recente verbindingen?',
'companionRemote.pairing.validationHostRequired' => 'Voer een hostadres in',
'companionRemote.pairing.validationHostFormat' => 'Formaat moet IP:poort zijn (bijv. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Voer een sessie-ID in',
@@ -1981,7 +1983,6 @@ extension on TranslationsNl {
'companionRemote.pairing.connectionTimedOut' => 'Verbinding verlopen. Controleer de sessie-ID en PIN.',
'companionRemote.pairing.sessionNotFound' => 'Kan de sessie niet vinden. Controleer je gegevens.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Verbinden mislukt: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Kan recente sessies niet laden: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Wil je de verbinding met de externe sessie verbreken?',
'companionRemote.remote.reconnecting' => 'Opnieuw verbinden...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Poging ${current} van 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsNl {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio-uitvoer',
'videoSettings.performanceOverlay' => 'Prestatie-overlay',
'videoSettings.audioPassthrough' => 'Audio-doorvoer',
'externalPlayer.title' => 'Externe speler',
'externalPlayer.useExternalPlayer' => 'Externe speler gebruiken',
'externalPlayer.useExternalPlayerDescription' => 'Open video\'s in een externe app in plaats van de ingebouwde speler',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonSv implements TranslationsCommonEn {
@override String get dontAskAgain => 'Fråga inte igen';
@override String get exit => 'Avsluta';
@override String get viewAll => 'Visa alla';
@override String get checkingNetwork => 'Kontrollerar nätverk...';
@override String get refreshingServers => 'Uppdaterar servrar...';
@override String get loadingServers => 'Laddar servrar...';
@override String get connectingToServers => 'Ansluter till servrar...';
@override String get startingOfflineMode => 'Startar offlineläge...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsSv implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => 'Sidofältet förblir expanderat och innehållsytan anpassas';
@override String get showUnwatchedCount => 'Visa antal osedda';
@override String get showUnwatchedCountDescription => 'Visa antal osedda avsnitt för serier och säsonger';
@override String get hideSpoilers => 'Dölj spoilers för osedda avsnitt';
@override String get hideSpoilersDescription => 'Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu';
@override String get playerBackend => 'Spelarmotor';
@override String get exoPlayer => 'ExoPlayer (Rekommenderad)';
@override String get exoPlayerDescription => 'Android-nativ spelare med bättre hårdvarustöd';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuSv implements TranslationsMediaMenuEn {
@override String get goToSeason => 'Gå till säsong';
@override String get shufflePlay => 'Blanda uppspelning';
@override String get fileInfo => 'Filinformation';
@override String get confirmDelete => 'Är du säker på att du vill ta bort detta objekt från ditt filsystem?';
@override String get deleteMultipleWarning => 'Flera objekt kan komma att tas bort.';
@override String get deleteFromServer => 'Ta bort från servern';
@override String get confirmDelete => 'Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.';
@override String get deleteMultipleWarning => 'Detta inkluderar alla avsnitt och deras filer.';
@override String get mediaDeletedSuccessfully => 'Mediaobjekt borttaget';
@override String get mediaFailedToDelete => 'Kunde inte ta bort mediaobjekt';
@override String get rate => 'Betygsätt';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsSv implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => 'Ljudutgång';
@override String get performanceOverlay => 'Prestandaöverlägg';
@override String get audioPassthrough => 'Ljudgenomkoppling';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
final TranslationsSv _root; // ignore: unused_field
// Translations
@override String get recent => 'Senaste';
@override String get scan => 'Skanna';
@override String get manual => 'Manuell';
@override String get recentConnections => 'Senaste anslutningar';
@override String get quickReconnect => 'Återanslut snabbt till tidigare parkopplade enheter';
@override String get pairWithDesktop => 'Parkoppla med dator';
@override String get enterSessionDetails => 'Ange sessionsuppgifterna som visas på din datorenhet';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => 'Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.';
@override String cameraError({required Object error}) => 'Kunde inte starta kameran: ${error}';
@override String get scanInstruction => 'Rikta kameran mot QR-koden som visas på din dator';
@override String get noRecentConnections => 'Inga senaste anslutningar';
@override String get connectUsingManual => 'Anslut till en enhet via Manuell inmatning för att komma igång';
@override String get invalidQrCode => 'Ogiltigt QR-kodformat';
@override String get removeRecentConnection => 'Ta bort senaste anslutning';
@override String removeConfirm({required Object name}) => 'Ta bort "${name}" från senaste anslutningar?';
@override String get validationHostRequired => 'Ange en värdadress';
@override String get validationHostFormat => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)';
@override String get validationSessionIdRequired => 'Ange ett sessions-ID';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingSv implements TranslationsCompanionRemo
@override String get connectionTimedOut => 'Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.';
@override String get sessionNotFound => 'Kunde inte hitta sessionen. Kontrollera dina uppgifter.';
@override String failedToConnect({required Object error}) => 'Kunde inte ansluta: ${error}';
@override String failedToLoadRecent({required Object error}) => 'Kunde inte ladda senaste sessioner: ${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsSv {
'common.dontAskAgain' => 'Fråga inte igen',
'common.exit' => 'Avsluta',
'common.viewAll' => 'Visa alla',
'common.checkingNetwork' => 'Kontrollerar nätverk...',
'common.refreshingServers' => 'Uppdaterar servrar...',
'common.loadingServers' => 'Laddar servrar...',
'common.connectingToServers' => 'Ansluter till servrar...',
'common.startingOfflineMode' => 'Startar offlineläge...',
'screens.licenses' => 'Licenser',
'screens.switchProfile' => 'Byt profil',
'screens.subtitleStyling' => 'Undertext-styling',
@@ -1401,6 +1407,8 @@ extension on TranslationsSv {
'settings.alwaysKeepSidebarOpenDescription' => 'Sidofältet förblir expanderat och innehållsytan anpassas',
'settings.showUnwatchedCount' => 'Visa antal osedda',
'settings.showUnwatchedCountDescription' => 'Visa antal osedda avsnitt för serier och säsonger',
'settings.hideSpoilers' => 'Dölj spoilers för osedda avsnitt',
'settings.hideSpoilersDescription' => 'Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu',
'settings.playerBackend' => 'Spelarmotor',
'settings.exoPlayer' => 'ExoPlayer (Rekommenderad)',
'settings.exoPlayerDescription' => 'Android-nativ spelare med bättre hårdvarustöd',
@@ -1538,8 +1546,9 @@ extension on TranslationsSv {
'mediaMenu.goToSeason' => 'Gå till säsong',
'mediaMenu.shufflePlay' => 'Blanda uppspelning',
'mediaMenu.fileInfo' => 'Filinformation',
'mediaMenu.confirmDelete' => 'Är du säker på att du vill ta bort detta objekt från ditt filsystem?',
'mediaMenu.deleteMultipleWarning' => 'Flera objekt kan komma att tas bort.',
'mediaMenu.deleteFromServer' => 'Ta bort från servern',
'mediaMenu.confirmDelete' => 'Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.',
'mediaMenu.deleteMultipleWarning' => 'Detta inkluderar alla avsnitt och deras filer.',
'mediaMenu.mediaDeletedSuccessfully' => 'Mediaobjekt borttaget',
'mediaMenu.mediaFailedToDelete' => 'Kunde inte ta bort mediaobjekt',
'mediaMenu.rate' => 'Betygsätt',
@@ -1949,11 +1958,8 @@ extension on TranslationsSv {
'companionRemote.session.copyToClipboard' => 'Kopiera till urklipp',
'companionRemote.session.newSession' => 'Ny session',
'companionRemote.session.minimize' => 'Minimera',
'companionRemote.pairing.recent' => 'Senaste',
'companionRemote.pairing.scan' => 'Skanna',
'companionRemote.pairing.manual' => 'Manuell',
'companionRemote.pairing.recentConnections' => 'Senaste anslutningar',
'companionRemote.pairing.quickReconnect' => 'Återanslut snabbt till tidigare parkopplade enheter',
'companionRemote.pairing.pairWithDesktop' => 'Parkoppla med dator',
'companionRemote.pairing.enterSessionDetails' => 'Ange sessionsuppgifterna som visas på din datorenhet',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsSv {
'companionRemote.pairing.cameraPermissionRequired' => 'Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.',
'companionRemote.pairing.cameraError' => ({required Object error}) => 'Kunde inte starta kameran: ${error}',
'companionRemote.pairing.scanInstruction' => 'Rikta kameran mot QR-koden som visas på din dator',
'companionRemote.pairing.noRecentConnections' => 'Inga senaste anslutningar',
'companionRemote.pairing.connectUsingManual' => 'Anslut till en enhet via Manuell inmatning för att komma igång',
'companionRemote.pairing.invalidQrCode' => 'Ogiltigt QR-kodformat',
'companionRemote.pairing.removeRecentConnection' => 'Ta bort senaste anslutning',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => 'Ta bort "${name}" från senaste anslutningar?',
'companionRemote.pairing.validationHostRequired' => 'Ange en värdadress',
'companionRemote.pairing.validationHostFormat' => 'Format måste vara IP:port (t.ex. 192.168.1.100:48632)',
'companionRemote.pairing.validationSessionIdRequired' => 'Ange ett sessions-ID',
@@ -1981,7 +1983,6 @@ extension on TranslationsSv {
'companionRemote.pairing.connectionTimedOut' => 'Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.',
'companionRemote.pairing.sessionNotFound' => 'Kunde inte hitta sessionen. Kontrollera dina uppgifter.',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => 'Kunde inte ansluta: ${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => 'Kunde inte ladda senaste sessioner: ${error}',
'companionRemote.remote.disconnectConfirm' => 'Vill du koppla från fjärrsessionen?',
'companionRemote.remote.reconnecting' => 'Återansluter...',
'companionRemote.remote.attemptOf' => ({required Object current}) => 'Försök ${current} av 5',
@@ -2019,6 +2020,7 @@ extension on TranslationsSv {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Ljudutgång',
'videoSettings.performanceOverlay' => 'Prestandaöverlägg',
'videoSettings.audioPassthrough' => 'Ljudgenomkoppling',
'externalPlayer.title' => 'Extern spelare',
'externalPlayer.useExternalPlayer' => 'Använd extern spelare',
'externalPlayer.useExternalPlayerDescription' => 'Öppna videor i en extern app istället för den inbyggda spelaren',
+22 -20
View File
@@ -151,6 +151,11 @@ class _TranslationsCommonZh implements TranslationsCommonEn {
@override String get dontAskAgain => '不再询问';
@override String get exit => '退出';
@override String get viewAll => '查看全部';
@override String get checkingNetwork => '正在检查网络...';
@override String get refreshingServers => '正在刷新服务器...';
@override String get loadingServers => '正在加载服务器...';
@override String get connectingToServers => '正在连接服务器...';
@override String get startingOfflineMode => '正在启动离线模式...';
}
// Path: screens
@@ -236,6 +241,8 @@ class _TranslationsSettingsZh implements TranslationsSettingsEn {
@override String get alwaysKeepSidebarOpenDescription => '侧边栏保持展开状态,内容区域自动调整';
@override String get showUnwatchedCount => '显示未观看数量';
@override String get showUnwatchedCountDescription => '在剧集和季上显示未观看的集数';
@override String get hideSpoilers => '隐藏未看剧集的剧透内容';
@override String get hideSpoilersDescription => '模糊未观看剧集的缩略图并隐藏其描述';
@override String get playerBackend => '播放器引擎';
@override String get exoPlayer => 'ExoPlayer(推荐)';
@override String get exoPlayerDescription => 'Android 原生播放器,硬件支持更好';
@@ -400,8 +407,9 @@ class _TranslationsMediaMenuZh implements TranslationsMediaMenuEn {
@override String get goToSeason => '转到季';
@override String get shufflePlay => '随机播放';
@override String get fileInfo => '文件信息';
@override String get confirmDelete => '确定要从文件系统中删除此项吗?';
@override String get deleteMultipleWarning => '可能会删除多个项目';
@override String get deleteFromServer => '从服务器删除';
@override String get confirmDelete => '这将永久删除此媒体及其文件。此操作无法撤销';
@override String get deleteMultipleWarning => '这包括所有剧集及其文件。';
@override String get mediaDeletedSuccessfully => '媒体项已成功删除';
@override String get mediaFailedToDelete => '删除媒体项失败';
@override String get rate => '评分';
@@ -1018,6 +1026,7 @@ class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn {
@override String get hdr => 'HDR';
@override String get audioOutput => '音频输出';
@override String get performanceOverlay => '性能监控';
@override String get audioPassthrough => '音频直通';
}
// Path: externalPlayer
@@ -1213,11 +1222,8 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
final TranslationsZh _root; // ignore: unused_field
// Translations
@override String get recent => '最近';
@override String get scan => '扫描';
@override String get manual => '手动';
@override String get recentConnections => '最近连接';
@override String get quickReconnect => '快速重新连接之前配对的设备';
@override String get pairWithDesktop => '与桌面配对';
@override String get enterSessionDetails => '输入桌面设备上显示的会话信息';
@override String get hostAddressHint => '192.168.1.100:48632';
@@ -1231,11 +1237,7 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
@override String get cameraPermissionRequired => '扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。';
@override String cameraError({required Object error}) => '无法启动相机:${error}';
@override String get scanInstruction => '将相机对准桌面上显示的 QR 码';
@override String get noRecentConnections => '没有最近的连接';
@override String get connectUsingManual => '使用手动输入连接设备以开始使用';
@override String get invalidQrCode => '无效的 QR 码格式';
@override String get removeRecentConnection => '删除最近连接';
@override String removeConfirm({required Object name}) => '确定要从最近连接中删除 "${name}" 吗?';
@override String get validationHostRequired => '请输入主机地址';
@override String get validationHostFormat => '格式必须为 IP:端口(例如 192.168.1.100:48632';
@override String get validationSessionIdRequired => '请输入会话 ID';
@@ -1245,7 +1247,6 @@ class _TranslationsCompanionRemotePairingZh implements TranslationsCompanionRemo
@override String get connectionTimedOut => '连接超时。请检查会话 ID 和 PIN。';
@override String get sessionNotFound => '找不到会话。请检查您的凭据。';
@override String failedToConnect({required Object error}) => '连接失败:${error}';
@override String failedToLoadRecent({required Object error}) => '加载最近会话失败:${error}';
}
// Path: companionRemote.remote
@@ -1343,6 +1344,11 @@ extension on TranslationsZh {
'common.dontAskAgain' => '不再询问',
'common.exit' => '退出',
'common.viewAll' => '查看全部',
'common.checkingNetwork' => '正在检查网络...',
'common.refreshingServers' => '正在刷新服务器...',
'common.loadingServers' => '正在加载服务器...',
'common.connectingToServers' => '正在连接服务器...',
'common.startingOfflineMode' => '正在启动离线模式...',
'screens.licenses' => '许可证',
'screens.switchProfile' => '切换用户',
'screens.subtitleStyling' => '字幕样式',
@@ -1401,6 +1407,8 @@ extension on TranslationsZh {
'settings.alwaysKeepSidebarOpenDescription' => '侧边栏保持展开状态,内容区域自动调整',
'settings.showUnwatchedCount' => '显示未观看数量',
'settings.showUnwatchedCountDescription' => '在剧集和季上显示未观看的集数',
'settings.hideSpoilers' => '隐藏未看剧集的剧透内容',
'settings.hideSpoilersDescription' => '模糊未观看剧集的缩略图并隐藏其描述',
'settings.playerBackend' => '播放器引擎',
'settings.exoPlayer' => 'ExoPlayer(推荐)',
'settings.exoPlayerDescription' => 'Android 原生播放器,硬件支持更好',
@@ -1538,8 +1546,9 @@ extension on TranslationsZh {
'mediaMenu.goToSeason' => '转到季',
'mediaMenu.shufflePlay' => '随机播放',
'mediaMenu.fileInfo' => '文件信息',
'mediaMenu.confirmDelete' => '确定要从文件系统中删除此项吗?',
'mediaMenu.deleteMultipleWarning' => '可能会删除多个项目',
'mediaMenu.deleteFromServer' => '从服务器删除',
'mediaMenu.confirmDelete' => '这将永久删除此媒体及其文件。此操作无法撤销',
'mediaMenu.deleteMultipleWarning' => '这包括所有剧集及其文件。',
'mediaMenu.mediaDeletedSuccessfully' => '媒体项已成功删除',
'mediaMenu.mediaFailedToDelete' => '删除媒体项失败',
'mediaMenu.rate' => '评分',
@@ -1949,11 +1958,8 @@ extension on TranslationsZh {
'companionRemote.session.copyToClipboard' => '复制到剪贴板',
'companionRemote.session.newSession' => '新建会话',
'companionRemote.session.minimize' => '最小化',
'companionRemote.pairing.recent' => '最近',
'companionRemote.pairing.scan' => '扫描',
'companionRemote.pairing.manual' => '手动',
'companionRemote.pairing.recentConnections' => '最近连接',
'companionRemote.pairing.quickReconnect' => '快速重新连接之前配对的设备',
'companionRemote.pairing.pairWithDesktop' => '与桌面配对',
'companionRemote.pairing.enterSessionDetails' => '输入桌面设备上显示的会话信息',
'companionRemote.pairing.hostAddressHint' => '192.168.1.100:48632',
@@ -1967,11 +1973,7 @@ extension on TranslationsZh {
'companionRemote.pairing.cameraPermissionRequired' => '扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。',
'companionRemote.pairing.cameraError' => ({required Object error}) => '无法启动相机:${error}',
'companionRemote.pairing.scanInstruction' => '将相机对准桌面上显示的 QR 码',
'companionRemote.pairing.noRecentConnections' => '没有最近的连接',
'companionRemote.pairing.connectUsingManual' => '使用手动输入连接设备以开始使用',
'companionRemote.pairing.invalidQrCode' => '无效的 QR 码格式',
'companionRemote.pairing.removeRecentConnection' => '删除最近连接',
'companionRemote.pairing.removeConfirm' => ({required Object name}) => '确定要从最近连接中删除 "${name}" 吗?',
'companionRemote.pairing.validationHostRequired' => '请输入主机地址',
'companionRemote.pairing.validationHostFormat' => '格式必须为 IP:端口(例如 192.168.1.100:48632',
'companionRemote.pairing.validationSessionIdRequired' => '请输入会话 ID',
@@ -1981,7 +1983,6 @@ extension on TranslationsZh {
'companionRemote.pairing.connectionTimedOut' => '连接超时。请检查会话 ID 和 PIN。',
'companionRemote.pairing.sessionNotFound' => '找不到会话。请检查您的凭据。',
'companionRemote.pairing.failedToConnect' => ({required Object error}) => '连接失败:${error}',
'companionRemote.pairing.failedToLoadRecent' => ({required Object error}) => '加载最近会话失败:${error}',
'companionRemote.remote.disconnectConfirm' => '是否要断开远程会话的连接?',
'companionRemote.remote.reconnecting' => '重新连接中...',
'companionRemote.remote.attemptOf' => ({required Object current}) => '${current} 次尝试,共 5 次',
@@ -2019,6 +2020,7 @@ extension on TranslationsZh {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音频输出',
'videoSettings.performanceOverlay' => '性能监控',
'videoSettings.audioPassthrough' => '音频直通',
'externalPlayer.title' => '外部播放器',
'externalPlayer.useExternalPlayer' => '使用外部播放器',
'externalPlayer.useExternalPlayerDescription' => '在外部应用中打开视频,而不是使用内置播放器',
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "Är du säker på att du vill avsluta?",
"dontAskAgain": "Fråga inte igen",
"exit": "Avsluta",
"viewAll": "Visa alla"
"viewAll": "Visa alla",
"checkingNetwork": "Kontrollerar nätverk...",
"refreshingServers": "Uppdaterar servrar...",
"loadingServers": "Laddar servrar...",
"connectingToServers": "Ansluter till servrar...",
"startingOfflineMode": "Startar offlineläge..."
},
"screens": {
"licenses": "Licenser",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "Sidofältet förblir expanderat och innehållsytan anpassas",
"showUnwatchedCount": "Visa antal osedda",
"showUnwatchedCountDescription": "Visa antal osedda avsnitt för serier och säsonger",
"hideSpoilers": "Dölj spoilers för osedda avsnitt",
"hideSpoilersDescription": "Gör miniatyrer suddiga och dölj beskrivningar för avsnitt du inte har sett ännu",
"playerBackend": "Spelarmotor",
"exoPlayer": "ExoPlayer (Rekommenderad)",
"exoPlayerDescription": "Android-nativ spelare med bättre hårdvarustöd",
@@ -266,8 +273,9 @@
"goToSeason": "Gå till säsong",
"shufflePlay": "Blanda uppspelning",
"fileInfo": "Filinformation",
"confirmDelete": "Är du säker på att du vill ta bort detta objekt från ditt filsystem?",
"deleteMultipleWarning": "Flera objekt kan komma att tas bort.",
"deleteFromServer": "Ta bort från servern",
"confirmDelete": "Detta kommer permanent ta bort detta media och dess filer från din server. Detta kan inte ångras.",
"deleteMultipleWarning": "Detta inkluderar alla avsnitt och deras filer.",
"mediaDeletedSuccessfully": "Mediaobjekt borttaget",
"mediaFailedToDelete": "Kunde inte ta bort mediaobjekt",
"rate": "Betygsätt"
@@ -732,11 +740,8 @@
"minimize": "Minimera"
},
"pairing": {
"recent": "Senaste",
"scan": "Skanna",
"manual": "Manuell",
"recentConnections": "Senaste anslutningar",
"quickReconnect": "Återanslut snabbt till tidigare parkopplade enheter",
"pairWithDesktop": "Parkoppla med dator",
"enterSessionDetails": "Ange sessionsuppgifterna som visas på din datorenhet",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "Kamerabehörighet krävs för att skanna QR-koder.\nVänligen ge kameraåtkomst i enhetsinställningarna.",
"cameraError": "Kunde inte starta kameran: ${error}",
"scanInstruction": "Rikta kameran mot QR-koden som visas på din dator",
"noRecentConnections": "Inga senaste anslutningar",
"connectUsingManual": "Anslut till en enhet via Manuell inmatning för att komma igång",
"invalidQrCode": "Ogiltigt QR-kodformat",
"removeRecentConnection": "Ta bort senaste anslutning",
"removeConfirm": "Ta bort \"${name}\" från senaste anslutningar?",
"validationHostRequired": "Ange en värdadress",
"validationHostFormat": "Format måste vara IP:port (t.ex. 192.168.1.100:48632)",
"validationSessionIdRequired": "Ange ett sessions-ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN måste vara 6 siffror",
"connectionTimedOut": "Anslutningen tog för lång tid. Kontrollera sessions-ID och PIN.",
"sessionNotFound": "Kunde inte hitta sessionen. Kontrollera dina uppgifter.",
"failedToConnect": "Kunde inte ansluta: ${error}",
"failedToLoadRecent": "Kunde inte ladda senaste sessioner: ${error}"
"failedToConnect": "Kunde inte ansluta: ${error}"
},
"remote": {
"disconnectConfirm": "Vill du koppla från fjärrsessionen?",
@@ -806,7 +806,8 @@
"subtitleSync": "Undertextsynkronisering",
"hdr": "HDR",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg"
"performanceOverlay": "Prestandaöverlägg",
"audioPassthrough": "Ljudgenomkoppling"
},
"externalPlayer": {
"title": "Extern spelare",
+14 -13
View File
@@ -52,7 +52,12 @@
"exitConfirmMessage": "确定要退出吗?",
"dontAskAgain": "不再询问",
"exit": "退出",
"viewAll": "查看全部"
"viewAll": "查看全部",
"checkingNetwork": "正在检查网络...",
"refreshingServers": "正在刷新服务器...",
"loadingServers": "正在加载服务器...",
"connectingToServers": "正在连接服务器...",
"startingOfflineMode": "正在启动离线模式..."
},
"screens": {
"licenses": "许可证",
@@ -117,6 +122,8 @@
"alwaysKeepSidebarOpenDescription": "侧边栏保持展开状态,内容区域自动调整",
"showUnwatchedCount": "显示未观看数量",
"showUnwatchedCountDescription": "在剧集和季上显示未观看的集数",
"hideSpoilers": "隐藏未看剧集的剧透内容",
"hideSpoilersDescription": "模糊未观看剧集的缩略图并隐藏其描述",
"playerBackend": "播放器引擎",
"exoPlayer": "ExoPlayer(推荐)",
"exoPlayerDescription": "Android 原生播放器,硬件支持更好",
@@ -266,8 +273,9 @@
"goToSeason": "转到季",
"shufflePlay": "随机播放",
"fileInfo": "文件信息",
"confirmDelete": "确定要从文件系统中删除此项吗?",
"deleteMultipleWarning": "可能会删除多个项目。",
"deleteFromServer": "从服务器删除",
"confirmDelete": "这将永久删除此媒体及其文件。此操作无法撤销。",
"deleteMultipleWarning": "这包括所有剧集及其文件。",
"mediaDeletedSuccessfully": "媒体项已成功删除",
"mediaFailedToDelete": "删除媒体项失败",
"rate": "评分"
@@ -732,11 +740,8 @@
"minimize": "最小化"
},
"pairing": {
"recent": "最近",
"scan": "扫描",
"manual": "手动",
"recentConnections": "最近连接",
"quickReconnect": "快速重新连接之前配对的设备",
"pairWithDesktop": "与桌面配对",
"enterSessionDetails": "输入桌面设备上显示的会话信息",
"hostAddressHint": "192.168.1.100:48632",
@@ -750,11 +755,7 @@
"cameraPermissionRequired": "扫描 QR 码需要相机权限。\n请在设备设置中授予相机访问权限。",
"cameraError": "无法启动相机:${error}",
"scanInstruction": "将相机对准桌面上显示的 QR 码",
"noRecentConnections": "没有最近的连接",
"connectUsingManual": "使用手动输入连接设备以开始使用",
"invalidQrCode": "无效的 QR 码格式",
"removeRecentConnection": "删除最近连接",
"removeConfirm": "确定要从最近连接中删除 \"${name}\" 吗?",
"validationHostRequired": "请输入主机地址",
"validationHostFormat": "格式必须为 IP:端口(例如 192.168.1.100:48632",
"validationSessionIdRequired": "请输入会话 ID",
@@ -763,8 +764,7 @@
"validationPinLength": "PIN 必须为6位数字",
"connectionTimedOut": "连接超时。请检查会话 ID 和 PIN。",
"sessionNotFound": "找不到会话。请检查您的凭据。",
"failedToConnect": "连接失败:${error}",
"failedToLoadRecent": "加载最近会话失败:${error}"
"failedToConnect": "连接失败:${error}"
},
"remote": {
"disconnectConfirm": "是否要断开远程会话的连接?",
@@ -806,7 +806,8 @@
"subtitleSync": "字幕同步",
"hdr": "HDR",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控"
"performanceOverlay": "性能监控",
"audioPassthrough": "音频直通"
},
"externalPlayer": {
"title": "外部播放器",
+72 -32
View File
@@ -4,10 +4,11 @@ import 'package:flutter/gestures.dart';
import 'dart:io' show Platform;
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'screens/main_screen.dart';
import 'screens/auth_screen.dart';
import 'services/storage_service.dart';
import 'services/macos_titlebar_service.dart';
import 'services/macos_window_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'services/settings_service.dart';
import 'utils/platform_detector.dart';
@@ -46,6 +47,7 @@ import 'i18n/strings.g.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'utils/navigation_transitions.dart';
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
// at (0,0) as barrier taps, causing modals to dismiss immediately.
@@ -79,7 +81,8 @@ void main() async {
await initializeDateFormatting(savedLocale.languageCode, null);
// Configure image cache for large libraries
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20; // 200MB
PaintingBinding.instance.imageCache.maximumSize = 2000; // default 1000
PaintingBinding.instance.imageCache.maximumSizeBytes = 300 << 20; // 300MB
// Initialize services in parallel where possible
final futures = <Future<void>>[];
@@ -97,7 +100,7 @@ void main() async {
}
// Configure macOS window with custom titlebar (depends on window manager)
futures.add(MacOSTitlebarService.setupCustomTitlebar());
futures.add(MacOSWindowService.setupCustomTitlebar());
// Initialize storage service
futures.add(StorageService.getInstance());
@@ -134,7 +137,7 @@ void main() async {
void _registerShaderLicenses() {
LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks(
yield const LicenseEntryWithLineBreaks(
['Anime4K'],
'MIT License\n'
'\n'
@@ -159,7 +162,7 @@ void _registerShaderLicenses() {
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE '
'SOFTWARE.',
);
yield LicenseEntryWithLineBreaks(
yield const LicenseEntryWithLineBreaks(
['NVIDIA Image Scaling (NVScaler)'],
'The MIT License (MIT)\n'
'\n'
@@ -367,31 +370,56 @@ class SetupScreen extends StatefulWidget {
}
class _SetupScreenState extends State<SetupScreen> {
String _statusMessage = '';
@override
void initState() {
super.initState();
_loadSavedCredentials();
}
void _setStatus(String message) {
if (mounted) setState(() => _statusMessage = message);
}
Future<void> _loadSavedCredentials() async {
_setStatus(t.common.checkingNetwork);
final storage = await StorageService.getInstance();
final registry = ServerRegistry(storage);
// Check network connectivity early to fast-path airplane mode
final connectivityResult = await Connectivity().checkConnectivity();
// Check network connectivity early to fast-path airplane mode.
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
final connectivityResult = await Connectivity().checkConnectivity().timeout(
const Duration(seconds: 3),
onTimeout: () => [ConnectivityResult.other],
);
final hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
if (hasNetwork) {
// Refresh servers from API to get updated connection info (IPs may change)
await registry.refreshServersFromApi();
_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) {
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
}
return;
}
}
_setStatus(t.common.loadingServers);
// Load all configured servers
final servers = await registry.getServers();
if (servers.isEmpty) {
if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const AuthScreen()));
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
}
return;
}
@@ -400,15 +428,15 @@ class _SetupScreenState extends State<SetupScreen> {
// No network — skip connection attempts and go straight to offline mode
if (!hasNetwork) {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
return;
}
_setStatus(t.common.connectingToServers);
try {
final result = await ServerConnectionOrchestrator.connectAndInitialize(
servers: servers,
@@ -427,40 +455,52 @@ class _SetupScreenState extends State<SetupScreen> {
downloadProvider.resumeQueuedDownloads(result.firstClient!);
});
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)),
);
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
} else {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
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;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)),
);
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [const CircularProgressIndicator(), const SizedBox(height: 16), Text(t.common.loading)],
),
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Stack(
children: [
// Icon dead-center, matching Android 12+ splash position.
// 192dp accounts for the 16% inset in ic_launcher.xml.
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
// Status text below center, independent of icon position.
Positioned(
left: 0,
right: 0,
bottom: MediaQuery.of(context).size.height * 0.5 - 140,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_statusMessage,
key: ValueKey(_statusMessage),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
),
),
),
],
),
);
}
@@ -1,55 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'recent_remote_session.g.dart';
/// Recent Companion Remote session for quick reconnection
@JsonSerializable()
class RecentRemoteSession {
final String sessionId;
final String pin;
final String deviceName;
final String platform;
final DateTime lastConnected;
final String? hostAddress; // Format: "ip:port"
RecentRemoteSession({
required this.sessionId,
required this.pin,
required this.deviceName,
required this.platform,
required this.lastConnected,
this.hostAddress,
});
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) => _$RecentRemoteSessionFromJson(json);
Map<String, dynamic> toJson() => _$RecentRemoteSessionToJson(this);
/// Create from QR code data (format: "ip1,ip2|port|sessionId|pin" or legacy "ip|port|sessionId|pin")
factory RecentRemoteSession.fromQrData(String qrData) {
final parts = qrData.split('|');
if (parts.length < 4) {
throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin');
}
final ipsField = parts.first;
final port = parts[1];
final sessionId = parts[2];
final pin = parts[3];
// Use the first IP for storage (comma-separated IPs supported in QR)
final firstIp = ipsField.split(',').first;
return RecentRemoteSession(
sessionId: sessionId,
pin: pin,
deviceName: 'Unknown Device',
platform: 'unknown',
lastConnected: DateTime.now(),
hostAddress: '$firstIp:$port',
);
}
@override
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
}
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recent_remote_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecentRemoteSession _$RecentRemoteSessionFromJson(Map<String, dynamic> json) => RecentRemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
lastConnected: DateTime.parse(json['lastConnected'] as String),
hostAddress: json['hostAddress'] as String?,
);
Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) => <String, dynamic>{
'sessionId': instance.sessionId,
'pin': instance.pin,
'deviceName': instance.deviceName,
'platform': instance.platform,
'lastConnected': instance.lastConnected.toIso8601String(),
'hostAddress': instance.hostAddress,
};
@@ -1,4 +1,56 @@
import 'remote_command_type.dart';
enum RemoteCommandType {
// Navigation
dpadUp,
dpadDown,
dpadLeft,
dpadRight,
select,
back,
contextMenu,
// Playback
play,
pause,
playPause,
stop,
seekForward,
seekBackward,
nextTrack,
previousTrack,
skipIntro,
skipCredits,
// Volume
volumeUp,
volumeDown,
volumeMute,
volumeSet,
// Tab Navigation
tabNext,
tabPrevious,
tabDiscover,
tabLibraries,
tabSearch,
tabDownloads,
tabSettings,
// Quick Actions
home,
search,
subtitles,
audioTracks,
qualitySettings,
fullscreen,
// Session Management
ping,
pong,
deviceInfo,
disconnect,
ack,
syncState,
}
class RemoteCommand {
final RemoteCommandType type;
@@ -1,53 +0,0 @@
enum RemoteCommandType {
// Navigation
dpadUp,
dpadDown,
dpadLeft,
dpadRight,
select,
back,
contextMenu,
// Playback
play,
pause,
playPause,
stop,
seekForward,
seekBackward,
nextTrack,
previousTrack,
skipIntro,
skipCredits,
// Volume
volumeUp,
volumeDown,
volumeMute,
volumeSet,
// Tab Navigation
tabNext,
tabPrevious,
tabDiscover,
tabLibraries,
tabSearch,
tabDownloads,
tabSettings,
// Quick Actions
home,
search,
subtitles,
audioTracks,
qualitySettings,
fullscreen,
// Session Management
ping,
pong,
deviceInfo,
disconnect,
ack,
syncState,
}
@@ -1,54 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'trusted_device.g.dart';
@JsonSerializable()
class TrustedDevice {
final String peerId;
final String deviceName;
final String platform;
final DateTime firstConnected;
final DateTime lastConnected;
final bool isApproved;
TrustedDevice({
required this.peerId,
required this.deviceName,
required this.platform,
DateTime? firstConnected,
DateTime? lastConnected,
this.isApproved = false,
}) : firstConnected = firstConnected ?? DateTime.now(),
lastConnected = lastConnected ?? DateTime.now();
factory TrustedDevice.fromJson(Map<String, dynamic> json) => _$TrustedDeviceFromJson(json);
Map<String, dynamic> toJson() => _$TrustedDeviceToJson(this);
TrustedDevice copyWith({
String? peerId,
String? deviceName,
String? platform,
DateTime? firstConnected,
DateTime? lastConnected,
bool? isApproved,
}) {
return TrustedDevice(
peerId: peerId ?? this.peerId,
deviceName: deviceName ?? this.deviceName,
platform: platform ?? this.platform,
firstConnected: firstConnected ?? this.firstConnected,
lastConnected: lastConnected ?? this.lastConnected,
isApproved: isApproved ?? this.isApproved,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TrustedDevice && other.peerId == peerId;
}
@override
int get hashCode => peerId.hashCode;
}
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'trusted_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TrustedDevice _$TrustedDeviceFromJson(Map<String, dynamic> json) => TrustedDevice(
peerId: json['peerId'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
firstConnected: json['firstConnected'] == null ? null : DateTime.parse(json['firstConnected'] as String),
lastConnected: json['lastConnected'] == null ? null : DateTime.parse(json['lastConnected'] as String),
isApproved: json['isApproved'] as bool? ?? false,
);
Map<String, dynamic> _$TrustedDeviceToJson(TrustedDevice instance) => <String, dynamic>{
'peerId': instance.peerId,
'deviceName': instance.deviceName,
'platform': instance.platform,
'firstConnected': instance.firstConnected.toIso8601String(),
'lastConnected': instance.lastConnected.toIso8601String(),
'isApproved': instance.isApproved,
};
+59 -1
View File
@@ -1,3 +1,4 @@
import '../utils/app_logger.dart';
import '../utils/codec_utils.dart';
class PlexMediaInfo {
@@ -15,6 +16,63 @@ class PlexMediaInfo {
this.partId,
});
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 = metadata['Media'] as List<dynamic>?;
if (media == null || media.isEmpty) return null;
final parts = media[0]['Part'] as List<dynamic>?;
if (parts == null || parts.isEmpty) return null;
final streams = parts[0]['Stream'] as List<dynamic>?;
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
if (streams != null) {
for (final s in streams) {
try {
final streamType = s['streamType'] as int?;
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: s['selected'] == 1 || s['selected'] == true,
));
} 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: s['selected'] == 1 || s['selected'] == true,
forced: s['forced'] == 1,
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 [],
);
}
}
/// Builds a track label from parts with the standard `' · '` joiner pattern.
@@ -173,7 +231,7 @@ class PlexMarker {
bool containsPosition(Duration position) {
final posMs = position.inMilliseconds;
return posMs >= startTimeOffset && posMs <= endTimeOffset;
return posMs >= startTimeOffset && posMs < endTimeOffset;
}
}
+29 -7
View File
@@ -92,6 +92,7 @@ class PlexMetadata with MultiServerFields {
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
final int? librarySectionID; // Library section ID this item belongs to
final String? librarySectionTitle; // Library section title this item belongs to
final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe)
final String? audienceRatingImage; // Audience rating source URI
final String? tagline;
@@ -111,6 +112,9 @@ class PlexMetadata with MultiServerFields {
// Clear logo URL (extracted from Image array, but serialized for offline storage)
final String? clearLogo;
// Square background art URL (extracted from Image array, used for near-square hero layouts)
final String? backgroundSquare;
/// Global unique identifier across all servers (serverId:ratingKey)
String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey;
@@ -174,6 +178,7 @@ class PlexMetadata with MultiServerFields {
this.playlistItemID,
this.playQueueItemID,
this.librarySectionID,
this.librarySectionTitle,
this.ratingImage,
this.audienceRatingImage,
this.tagline,
@@ -184,6 +189,7 @@ class PlexMetadata with MultiServerFields {
this.serverId,
this.serverName,
this.clearLogo,
this.backgroundSquare,
});
/// Create a copy of this metadata with optional field overrides
@@ -229,6 +235,7 @@ class PlexMetadata with MultiServerFields {
int? playlistItemID,
int? playQueueItemID,
int? librarySectionID,
String? librarySectionTitle,
String? ratingImage,
String? audienceRatingImage,
String? tagline,
@@ -239,6 +246,7 @@ class PlexMetadata with MultiServerFields {
String? serverId,
String? serverName,
String? clearLogo,
String? backgroundSquare,
}) {
return PlexMetadata(
ratingKey: ratingKey ?? this.ratingKey,
@@ -282,6 +290,7 @@ class PlexMetadata with MultiServerFields {
playlistItemID: playlistItemID ?? this.playlistItemID,
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
librarySectionTitle: librarySectionTitle ?? this.librarySectionTitle,
ratingImage: ratingImage ?? this.ratingImage,
audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
tagline: tagline ?? this.tagline,
@@ -292,35 +301,48 @@ class PlexMetadata with MultiServerFields {
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
clearLogo: clearLogo ?? this.clearLogo,
backgroundSquare: backgroundSquare ?? this.backgroundSquare,
);
}
/// Extract clearLogo from Image array in raw JSON
static String? _extractClearLogoFromJson(Map<String, dynamic> json) {
/// Extract an image URL by type from the Image array in raw JSON
static String? _extractImageFromJson(Map<String, dynamic> json, String imageType) {
if (!json.containsKey('Image')) return null;
final images = json['Image'] as List?;
if (images == null) return null;
for (var image in images) {
if (image is Map && image['type'] == 'clearLogo') {
if (image is Map && image['type'] == imageType) {
return image['url'] as String?;
}
}
return null;
}
/// Create from JSON with clearLogo extracted from Image array
/// Create from JSON with Image array fields extracted
factory PlexMetadata.fromJsonWithImages(Map<String, dynamic> json) {
// Extract clearLogo before parsing
final clearLogoUrl = _extractClearLogoFromJson(json);
// Add it to the json so it gets parsed
final clearLogoUrl = _extractImageFromJson(json, 'clearLogo');
if (clearLogoUrl != null) {
json['clearLogo'] = clearLogoUrl;
}
final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare');
if (backgroundSquareUrl != null) {
json['backgroundSquare'] = backgroundSquareUrl;
}
return PlexMetadata.fromJson(json);
}
/// Returns the best hero art path based on the container's aspect ratio.
/// Uses backgroundSquare when the container is closer to 1:1 than 16:9.
String? heroArt({required double containerAspectRatio}) {
// Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39
if (containerAspectRatio < 1.39 && backgroundSquare != null) {
return backgroundSquare;
}
return art;
}
// Helper to get the display title (show name for episodes/seasons, title otherwise)
String get displayTitle {
final itemType = type.toLowerCase();
+4
View File
@@ -48,6 +48,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
librarySectionTitle: json['librarySectionTitle'] as String?,
ratingImage: json['ratingImage'] as String?,
audienceRatingImage: json['audienceRatingImage'] as String?,
tagline: json['tagline'] as String?,
@@ -56,6 +57,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
extraType: (json['extraType'] as num?)?.toInt(),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
@@ -100,6 +102,7 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'librarySectionTitle': instance.librarySectionTitle,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'tagline': instance.tagline,
@@ -108,4 +111,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'extraType': instance.extraType,
'primaryExtraKey': instance.primaryExtraKey,
'clearLogo': instance.clearLogo,
'backgroundSquare': instance.backgroundSquare,
};
@@ -1,7 +1,7 @@
import 'package:flutter/services.dart';
import '../models.dart';
import 'player_base.dart';
import '../../models.dart';
import '../player_base.dart';
/// Android implementation of [Player] using ExoPlayer.
/// Provides hardware-accelerated playback with ASS subtitle support via libass-android.
@@ -36,12 +36,6 @@ class PlayerAndroid extends PlayerBase {
return;
}
if (name == 'memory-pressure') {
// System memory is critically low — playback may be at risk of OOM crash
errorController.add('Low memory — playback may be unstable');
return;
}
// Delegate to base class for common events
super.handlePlayerEvent(name, data);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import 'dart:io' show Platform;
import '../models.dart';
import 'player_android.dart';
import 'platform/player_android.dart';
import 'player_native.dart';
import 'player_state.dart';
import 'player_streams.dart';
+8 -4
View File
@@ -40,6 +40,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
bool _disposed = false;
final _throttleSw = Stopwatch()..start();
int _lastEmitMs = 0;
int _lastCacheStateMs = 0;
int _positionMs = 0;
int _nextPropId = 0;
final Map<int, String> _propIdToName = {};
@@ -173,6 +174,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
case 'demuxer-cache-time':
if (value is num) {
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) break;
_lastCacheStateMs = nowMs;
final buffer = Duration(milliseconds: (value * 1000).toInt());
_state = _state.copyWith(buffer: buffer);
bufferController.add(buffer);
@@ -274,6 +278,10 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
if (value is Map) {
cacheState = value;
} else if (value is String && value.isNotEmpty) {
// Throttle JSON parsing to avoid ANR on low-end devices
final nowMs = _throttleSw.elapsedMilliseconds;
if (nowMs - _lastCacheStateMs < 250) return;
_lastCacheStateMs = nowMs;
try {
final parsed = jsonDecode(value);
if (parsed is Map) cacheState = parsed;
@@ -331,10 +339,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
break;
case 'playback-restart':
// Clear stale buffer ranges from before the seek; fresh ones will
// arrive shortly via the next demuxer-cache-state update.
_state = _state.copyWith(bufferRanges: const []);
bufferRangesController.add(const []);
playbackRestartController.add(null);
break;
+1 -2
View File
@@ -282,8 +282,7 @@ class PlayerNative extends PlayerBase {
Future<void> updateFrame() async {
checkDisposed();
if (!initialized) return;
// Only iOS and macOS use Metal layer that needs frame updates
if (Platform.isIOS || Platform.isMacOS) {
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
await methodChannel.invokeMethod('updateFrame');
}
}
+2 -191
View File
@@ -1,35 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import '../models/companion_remote/remote_command.dart';
import '../models/companion_remote/remote_command_type.dart';
import '../models/companion_remote/remote_session.dart';
import '../models/companion_remote/trusted_device.dart';
import '../services/companion_remote/companion_remote_peer_service.dart';
import '../models/companion_remote/recent_remote_session.dart';
import '../services/companion_remote/companion_remote_discovery_service.dart';
import '../services/storage_service.dart';
import '../utils/app_logger.dart';
typedef CommandReceivedCallback = void Function(RemoteCommand command);
typedef DeviceApprovalCallback = Future<bool> Function(RemoteDevice device);
class CompanionRemoteProvider with ChangeNotifier {
RemoteSession? _session;
CompanionRemotePeerService? _peerService;
CompanionRemoteDiscoveryService? _discoveryService;
String _deviceName = 'Unknown Device';
String _platform = 'unknown';
final List<TrustedDevice> _trustedDevices = [];
final List<RecentRemoteSession> _recentSessions = [];
bool _isPlayerActive = false;
static const String _storageKey = 'companion_remote_trusted_devices';
static const String _lastDeviceKey = 'companion_remote_last_device';
static const int _maxReconnectAttempts = 5;
Timer? _reconnectTimer;
@@ -46,10 +34,8 @@ class CompanionRemoteProvider with ChangeNotifier {
StreamSubscription<void>? _deviceDisconnectedSubscription;
StreamSubscription<RemotePeerError>? _errorSubscription;
StreamSubscription<RemoteSessionStatus>? _statusSubscription;
StreamSubscription<List<RecentRemoteSession>>? _recentSessionsSubscription;
CommandReceivedCallback? onCommandReceived;
DeviceApprovalCallback? onDeviceApprovalRequired;
bool get isInSession => _session != null && _session!.status != RemoteSessionStatus.disconnected;
bool get isHost => _session?.isHost ?? false;
@@ -60,13 +46,10 @@ class CompanionRemoteProvider with ChangeNotifier {
String? get sessionId => _session?.sessionId;
String? get pin => _session?.pin;
RemoteDevice? get connectedDevice => _session?.connectedDevice;
List<TrustedDevice> get trustedDevices => List.unmodifiable(_trustedDevices);
List<RecentRemoteSession> get recentSessions => List.unmodifiable(_recentSessions);
bool get isPlayerActive => _isPlayerActive;
CompanionRemoteProvider() {
_initializeDeviceInfo();
_loadTrustedDevices();
}
Future<void> _initializeDeviceInfo() async {
@@ -123,12 +106,10 @@ class CompanionRemoteProvider with ChangeNotifier {
},
);
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) async {
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) {
appLogger.d('CompanionRemote: Device connected: ${device.name}');
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
notifyListeners();
await addTrustedDevice(device, requireApproval: isHost);
});
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
@@ -165,7 +146,7 @@ class CompanionRemoteProvider with ChangeNotifier {
});
}
Future<void> _handleDeviceInfo(RemoteCommand command) async {
void _handleDeviceInfo(RemoteCommand command) {
if (command.data != null) {
final id = command.data!['id'] as String? ?? 'unknown';
final name = command.data!['name'] as String? ?? 'Unknown Device';
@@ -178,9 +159,6 @@ class CompanionRemoteProvider with ChangeNotifier {
_session = _session?.copyWith(connectedDevice: device);
notifyListeners();
// Save to recent sessions now that we have the remote device's real identity
await _addToRecentSessions();
}
}
@@ -384,177 +362,10 @@ class CompanionRemoteProvider with ChangeNotifier {
notifyListeners();
}
Future<void> _loadTrustedDevices() async {
try {
final storage = await StorageService.getInstance();
final json = storage.prefs.getString(_storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_trustedDevices.clear();
_trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>)));
appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices');
}
} catch (e) {
appLogger.e('CompanionRemote: Failed to load trusted devices', error: e);
}
}
Future<void> _saveTrustedDevices() async {
try {
final storage = await StorageService.getInstance();
final json = jsonEncode(_trustedDevices.map((e) => e.toJson()).toList());
await storage.prefs.setString(_storageKey, json);
appLogger.d('CompanionRemote: Saved ${_trustedDevices.length} trusted devices');
} catch (e) {
appLogger.e('CompanionRemote: Failed to save trusted devices', error: e);
}
}
bool isDeviceTrusted(String peerId) {
return _trustedDevices.any((d) => d.peerId == peerId && d.isApproved);
}
Future<void> addTrustedDevice(RemoteDevice device, {bool requireApproval = true}) async {
final existing = _trustedDevices.where((d) => d.peerId == device.id).firstOrNull;
if (existing != null) {
final updated = existing.copyWith(
deviceName: device.name,
platform: device.platform,
lastConnected: DateTime.now(),
isApproved: !requireApproval || existing.isApproved,
);
_trustedDevices.remove(existing);
_trustedDevices.add(updated);
} else {
bool approved = !requireApproval;
if (requireApproval && onDeviceApprovalRequired != null) {
approved = await onDeviceApprovalRequired!(device);
}
_trustedDevices.add(
TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved),
);
}
await _saveTrustedDevices();
if (isRemote) {
final storage = await StorageService.getInstance();
await storage.prefs.setString(_lastDeviceKey, device.id);
}
notifyListeners();
}
Future<void> removeTrustedDevice(String peerId) async {
_trustedDevices.removeWhere((d) => d.peerId == peerId);
await _saveTrustedDevices();
notifyListeners();
}
Future<void> approveTrustedDevice(String peerId) async {
final device = _trustedDevices.where((d) => d.peerId == peerId).firstOrNull;
if (device != null) {
final updated = device.copyWith(isApproved: true);
_trustedDevices.remove(device);
_trustedDevices.add(updated);
await _saveTrustedDevices();
notifyListeners();
}
}
Future<String?> getLastConnectedDevicePeerId() async {
final storage = await StorageService.getInstance();
return storage.prefs.getString(_lastDeviceKey);
}
/// Load recent sessions
Future<void> loadRecentSessions() async {
try {
// Dispose previous discovery service and subscription to avoid leaks
_recentSessionsSubscription?.cancel();
_recentSessionsSubscription = null;
_discoveryService?.dispose();
_discoveryService = CompanionRemoteDiscoveryService();
// Listen for recent sessions updates
_recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) {
_recentSessions.clear();
_recentSessions.addAll(sessions);
notifyListeners();
});
// Initial load happens in constructor, just notify
_recentSessions.clear();
_recentSessions.addAll(_discoveryService!.currentSessions);
notifyListeners();
appLogger.d('CompanionRemote: Loaded ${_recentSessions.length} recent sessions');
} catch (e) {
appLogger.e('CompanionRemote: Failed to load recent sessions', error: e);
}
}
/// Add current session to recent list (called after successful connection)
Future<void> _addToRecentSessions() async {
if (_session == null || _session!.sessionId.isEmpty) return;
// For mobile (remote role), save the connected desktop device
// For desktop (host role), this doesn't really apply but save connected mobile device
final deviceToSave = _session!.connectedDevice;
if (deviceToSave == null) {
appLogger.w('CompanionRemote: No connected device to save to recent sessions');
return;
}
final recentSession = RecentRemoteSession(
sessionId: _session!.sessionId,
pin: _session!.pin,
deviceName: deviceToSave.name,
platform: deviceToSave.platform,
lastConnected: DateTime.now(),
hostAddress: _peerService?.hostAddress,
);
if (_discoveryService != null) {
await _discoveryService!.addRecentSession(recentSession);
}
}
/// Connect to a recent session
Future<void> connectToRecentSession(RecentRemoteSession session) async {
if (session.hostAddress == null) {
throw const RemotePeerError(
type: RemotePeerErrorType.invalidSession,
message: 'No host address available for this session. Please scan a new QR code.',
);
}
await joinSession(session.sessionId, session.pin, session.hostAddress!);
}
/// Remove a recent session
Future<void> removeRecentSession(String sessionId) async {
if (_discoveryService != null) {
await _discoveryService!.removeRecentSession(sessionId);
}
}
/// Clear all recent sessions
Future<void> clearRecentSessions() async {
if (_discoveryService != null) {
await _discoveryService!.clearRecentSessions();
}
}
@override
void dispose() {
_reconnectTimer?.cancel();
leaveSession();
_recentSessionsSubscription?.cancel();
_discoveryService?.dispose();
super.dispose();
}
}
+10 -11
View File
@@ -656,19 +656,18 @@ class DownloadProvider extends ChangeNotifier {
}
}
// Fetch full metadata to get year, summary, clearLogo
// The metadata from getChildren() is summarized and missing these fields.
// If metadata already has summary, it's already full (e.g., from detail screen).
// Always fetch full metadata before downloading.
// Hub items may have summary but the cache at /library/metadata/$ratingKey
// won't have the full API response (with Media/Part data needed for video URL)
// unless getMetadataWithImages has been called.
PlexMetadata metadataToStore = metadata;
if (metadata.summary == null) {
try {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
try {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
}
// For episodes, also fetch and store show and season metadata for offline display
+5 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../services/plex_client.dart';
@@ -20,6 +22,7 @@ class LiveTvServerInfo {
class MultiServerProvider extends ChangeNotifier {
final MultiServerManager _serverManager;
final DataAggregationService _aggregationService;
StreamSubscription? _statusSubscription;
/// Whether any connected server has Live TV / DVR
bool _hasLiveTv = false;
@@ -31,7 +34,7 @@ class MultiServerProvider extends ChangeNotifier {
MultiServerProvider(this._serverManager, this._aggregationService) {
// Listen to server status changes
_serverManager.statusStream.listen((_) {
_statusSubscription = _serverManager.statusStream.listen((_) {
notifyListeners();
// Re-check live TV availability when servers come online
checkLiveTvAvailability();
@@ -145,6 +148,7 @@ class MultiServerProvider extends ChangeNotifier {
@override
void dispose() {
_statusSubscription?.cancel();
_serverManager.dispose();
super.dispose();
}
+3 -1
View File
@@ -28,7 +28,9 @@ class OfflineModeProvider extends ChangeNotifier {
/// Updates network and server connection flags
Future<void> _updateConnectionFlags() async {
final connectivityResult = await Connectivity().checkConnectivity();
final connectivityResult = await Connectivity()
.checkConnectivity()
.timeout(const Duration(seconds: 3), onTimeout: () => [ConnectivityResult.other]);
_hasNetworkConnection = !connectivityResult.contains(ConnectivityResult.none);
_hasServerConnection = _serverManager.onlineServerIds.isNotEmpty;
}
+10
View File
@@ -12,6 +12,7 @@ class SettingsProvider extends ChangeNotifier {
bool _showServerNameOnHubs = false;
bool _alwaysKeepSidebarOpen = false;
bool _showUnwatchedCount = true;
bool _hideSpoilers = false;
bool _isInitialized = false;
Future<void>? _initFuture;
@@ -36,6 +37,7 @@ class SettingsProvider extends ChangeNotifier {
_showServerNameOnHubs = _settingsService!.getShowServerNameOnHubs();
_alwaysKeepSidebarOpen = _settingsService!.getAlwaysKeepSidebarOpen();
_showUnwatchedCount = _settingsService!.getShowUnwatchedCount();
_hideSpoilers = _settingsService!.getHideSpoilers();
_isInitialized = true;
notifyListeners();
}
@@ -59,6 +61,8 @@ class SettingsProvider extends ChangeNotifier {
bool get showUnwatchedCount => _showUnwatchedCount;
bool get hideSpoilers => _hideSpoilers;
/// Helper to update a setting: ensures init, deduplicates, persists, notifies.
Future<void> _updateSetting<T>({
required T current,
@@ -122,6 +126,12 @@ class SettingsProvider extends ChangeNotifier {
persist: _settingsService!.setShowUnwatchedCount,
);
Future<void> setHideSpoilers(bool value) => _updateSetting(
current: _hideSpoilers, value: value,
setLocal: (v) => _hideSpoilers = v,
persist: _settingsService!.setHideSpoilers,
);
String get libraryDensityDisplayName {
switch (_libraryDensity) {
case LibraryDensity.compact:
+17
View File
@@ -1,4 +1,6 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../services/settings_service.dart' as settings;
import '../theme/mono_theme.dart';
@@ -24,6 +26,7 @@ class ThemeProvider extends ChangeNotifier {
Future<void> _initializeSettings() async {
_settingsService = await settings.SettingsService.getInstance();
_themeMode = _settingsService.getThemeMode();
_updateSplashTheme(_themeMode);
notifyListeners();
}
@@ -63,14 +66,28 @@ class ThemeProvider extends ChangeNotifier {
}
}
static const _themeChannel = MethodChannel('app.plezy/theme');
Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode != mode) {
_themeMode = mode;
await _settingsService.setThemeMode(mode);
_updateSplashTheme(mode);
notifyListeners();
}
}
void _updateSplashTheme(settings.ThemeMode mode) {
if (!Platform.isAndroid) return;
final name = switch (mode) {
settings.ThemeMode.dark => 'dark',
settings.ThemeMode.oled => 'oled',
settings.ThemeMode.light => 'light',
settings.ThemeMode.system => 'system',
};
_themeChannel.invokeMethod('setSplashTheme', {'mode': name});
}
String get themeModeDisplayName {
switch (_themeMode) {
case settings.ThemeMode.light:
+4 -5
View File
@@ -17,6 +17,7 @@ import '../theme/mono_tokens.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../utils/navigation_transitions.dart';
import 'main_screen.dart';
class AuthScreen extends StatefulWidget {
@@ -96,6 +97,7 @@ class _AuthScreenState extends State<AuthScreen> {
multiServerProvider: context.read<MultiServerProvider>(),
librariesProvider: context.read<LibrariesProvider>(),
syncService: context.read<OfflineWatchSyncService>(),
clientIdentifier: _authService.clientIdentifier,
);
if (!result.hasConnections) {
@@ -112,10 +114,7 @@ class _AuthScreenState extends State<AuthScreen> {
await profileFuture;
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: result.firstClient!)),
);
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
setState(() {
@@ -454,7 +453,7 @@ class _AuthScreenState extends State<AuthScreen> {
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
),
child: Text(t.auth.debugEnterToken, style: TextStyle(fontSize: 12)),
child: Text(t.auth.debugEnterToken, style: const TextStyle(fontSize: 12)),
),
],
if (_errorMessage != null) ...[
+9 -20
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_action_bar.dart';
import '../models/plex_metadata.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
@@ -38,9 +39,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
@override
bool get hasItems => items.isNotEmpty;
@override
int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty)
@override
void dispose() {
disposeFocusResources();
@@ -69,23 +67,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
}
@override
List<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
if (items.isNotEmpty) {
buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems));
buttons.add(
AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
);
}
buttons.add(
AppBarButtonConfig(
icon: Symbols.delete_rounded,
tooltip: t.common.delete,
onPressed: _deleteCollection,
color: Colors.red,
),
);
return buttons;
List<FocusableAction> getAppBarActions() {
return [
if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
],
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red),
];
}
Future<void> _deleteCollection() async {
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../i18n/strings.g.dart';
import '../../providers/companion_remote_provider.dart';
@@ -201,7 +201,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle),
decoration: const BoxDecoration(color: Colors.green, shape: BoxShape.circle),
),
],
),
@@ -648,7 +648,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> {
hintText: t.companionRemote.remote.searchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
border: OutlineInputBorder(borderRadius: const BorderRadius.all(Radius.circular(100))),
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(100))),
),
onSubmitted: _submit,
),
+20 -219
View File
@@ -5,11 +5,8 @@ import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../providers/companion_remote_provider.dart';
import '../../utils/formatters.dart';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../utils/app_logger.dart';
class PairingScreen extends StatefulWidget {
@@ -25,8 +22,6 @@ class _PairingScreenState extends State<PairingScreen> {
final _pinController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isConnecting = false;
String? _connectingSessionId;
bool _isDiscovering = false;
String? _errorMessage;
int _selectedTab = 0;
@@ -36,15 +31,9 @@ class _PairingScreenState extends State<PairingScreen> {
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
// Tab indices shift when scan tab is present
int get _scanTabIndex => _isMobile ? 1 : -1;
int get _manualTabIndex => _isMobile ? 2 : 1;
@override
void initState() {
super.initState();
_loadRecentSessions();
}
// Tab indices: mobile gets Scan (0) + Manual (1), desktop gets Manual (0)
int get _scanTabIndex => _isMobile ? 0 : -1;
int get _manualTabIndex => _isMobile ? 1 : 0;
@override
void dispose() {
@@ -55,51 +44,6 @@ class _PairingScreenState extends State<PairingScreen> {
super.dispose();
}
Future<void> _loadRecentSessions() async {
setState(() {
_isDiscovering = true;
_errorMessage = null;
});
try {
await context.read<CompanionRemoteProvider>().loadRecentSessions();
if (!mounted) return;
setState(() {
_isDiscovering = false;
});
} catch (e) {
appLogger.e('Failed to load recent sessions', error: e);
if (!mounted) return;
setState(() {
_isDiscovering = false;
_errorMessage = t.companionRemote.pairing.failedToLoadRecent(error: e.toString());
});
}
}
Future<void> _connectToRecentSession(RecentRemoteSession session) async {
setState(() {
_isConnecting = true;
_connectingSessionId = session.sessionId;
_errorMessage = null;
});
try {
await context.read<CompanionRemoteProvider>().connectToRecentSession(session);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
appLogger.e('Failed to connect to recent session', error: e);
setState(() {
_isConnecting = false;
_connectingSessionId = null;
_errorMessage = _parseErrorMessage(e.toString());
});
}
}
Future<void> _connect() async {
if (!_formKey.currentState!.validate()) {
return;
@@ -209,39 +153,30 @@ class _PairingScreenState extends State<PairingScreen> {
return Scaffold(
appBar: AppBar(
title: Text(t.companionRemote.connectToDevice),
actions: [
if (_selectedTab == 0)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _isDiscovering ? null : _loadRecentSessions,
tooltip: t.common.refresh,
),
],
),
body: Column(
children: [
SegmentedButton<int>(
segments: [
ButtonSegment(value: 0, label: Text(t.companionRemote.pairing.recent), icon: const Icon(Icons.history)),
if (_isMobile)
if (_isMobile)
SegmentedButton<int>(
segments: [
ButtonSegment(
value: _scanTabIndex,
label: Text(t.companionRemote.pairing.scan),
icon: const Icon(Icons.qr_code_scanner),
),
ButtonSegment(
value: _manualTabIndex,
label: Text(t.companionRemote.pairing.manual),
icon: const Icon(Icons.keyboard),
),
],
selected: {_selectedTab},
onSelectionChanged: (Set<int> selection) {
setState(() {
_selectedTab = selection.first;
});
},
),
ButtonSegment(
value: _manualTabIndex,
label: Text(t.companionRemote.pairing.manual),
icon: const Icon(Icons.keyboard),
),
],
selected: {_selectedTab},
onSelectionChanged: (Set<int> selection) {
setState(() {
_selectedTab = selection.first;
});
},
),
Expanded(child: _buildTabContent()),
],
),
@@ -249,8 +184,7 @@ class _PairingScreenState extends State<PairingScreen> {
}
Widget _buildTabContent() {
if (_selectedTab == 0) return _buildDiscoveryTab();
if (_selectedTab == _scanTabIndex) return _buildScanTab();
if (_selectedTab == _scanTabIndex && _isMobile) return _buildScanTab();
return _buildManualEntryTab();
}
@@ -337,139 +271,6 @@ class _PairingScreenState extends State<PairingScreen> {
);
}
Widget _buildDiscoveryTab() {
return Consumer<CompanionRemoteProvider>(
builder: (context, provider, child) {
final sessions = provider.recentSessions;
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.history, size: 64, color: Colors.blue),
const SizedBox(height: 24),
Text(
t.companionRemote.pairing.recentConnections,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
t.companionRemote.pairing.quickReconnect,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
if (_isDiscovering) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(t.common.loading, style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center),
] else if (sessions.isEmpty) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Icon(Icons.devices_other, size: 48, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 16),
Text(
t.companionRemote.pairing.noRecentConnections,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
t.companionRemote.pairing.connectUsingManual,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
...sessions.map((session) {
final isThisConnecting = _isConnecting && _connectingSessionId == session.sessionId;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(Icons.computer, size: 40),
title: Text(session.deviceName),
subtitle: Text(
'${session.platform}\n'
'Session: ${session.sessionId}\n'
'Last used: ${_formatDate(session.lastConnected)}',
),
isThreeLine: true,
trailing: isThisConnecting
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.arrow_forward),
onTap: _isConnecting ? null : () => _connectToRecentSession(session),
onLongPress: () => _showRemoveSessionDialog(session),
),
);
}),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
),
),
),
],
],
),
);
},
);
}
String _formatDate(DateTime date) {
return formatRelativeTime(date);
}
Future<void> _showRemoveSessionDialog(RecentRemoteSession session) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.companionRemote.pairing.removeRecentConnection),
content: Text(t.companionRemote.pairing.removeConfirm(name: session.deviceName)),
actions: [
FocusableButton(
autofocus: true,
onPressed: () => Navigator.pop(context, false),
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(t.common.cancel),
),
),
FocusableButton(
onPressed: () => Navigator.pop(context, true),
child: TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.remove)),
),
],
),
);
if (confirmed == true && mounted) {
await context.read<CompanionRemoteProvider>().removeRecentSession(session.sessionId);
}
}
Widget _buildManualEntryTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
+155 -260
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/key_event_utils.dart';
import '../utils/global_key_utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -131,14 +132,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Hero and app bar focus
late FocusNode _heroFocusNode;
late FocusNode _refreshButtonFocusNode;
late FocusNode _watchTogetherButtonFocusNode;
late FocusNode _companionRemoteButtonFocusNode;
late FocusNode _userButtonFocusNode;
bool _isRefreshFocused = false;
bool _isWatchTogetherFocused = false;
bool _isCompanionRemoteFocused = false;
bool _isUserFocused = false;
final _actionBarKey = GlobalKey<FocusableActionBarState>();
/// Get the correct PlexClient for an item's server
PlexClient _getClientForItem(PlexMetadata? item) {
@@ -188,7 +182,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isHeroSectionVisible) {
_heroFocusNode.requestFocus();
} else {
_refreshButtonFocusNode.requestFocus();
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
}
_scrollToTop();
}
@@ -245,14 +239,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
_companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button');
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
_companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange);
_userButtonFocusNode.addListener(_onUserFocusChange);
_loadContent();
_startAutoScroll();
}
@@ -272,45 +258,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_loadContent();
}
void _onRefreshFocusChange() {
if (mounted) {
setState(() {
_isRefreshFocused = _refreshButtonFocusNode.hasFocus;
});
}
}
void _onWatchTogetherFocusChange() {
if (mounted) {
setState(() {
_isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus;
});
}
}
void _onCompanionRemoteFocusChange() {
if (mounted) {
setState(() {
_isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus;
});
}
}
void _onUserFocusChange() {
if (mounted) {
setState(() {
_isUserFocused = _userButtonFocusNode.hasFocus;
});
}
}
/// Handle key events for the hero section
late final _handleHeroKeyEvent = dpadKeyHandler(
onDown: () {
final keys = _allHubKeys;
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
},
onUp: () => _refreshButtonFocusNode.requestFocus(),
onUp: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
onLeft: () {
if (_currentHeroIndex > 0) {
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
@@ -330,45 +284,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
},
);
/// Handle key events for the refresh button in app bar
late final _handleRefreshKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onRight: () => _watchTogetherButtonFocusNode.requestFocus(),
onLeft: _navigateToSidebar,
onUp: () {}, // Block at boundary
onSelect: _loadContent,
);
/// Handle key events for the watch together button in app bar
late final _handleWatchTogetherKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _refreshButtonFocusNode.requestFocus(),
onRight: () => _companionRemoteButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
);
/// Handle key events for the companion remote button in app bar
late final _handleCompanionRemoteKeyEvent = dpadKeyHandler(
onDown: () => _heroFocusNode.requestFocus(),
onLeft: () => _watchTogetherButtonFocusNode.requestFocus(),
onRight: () => _userButtonFocusNode.requestFocus(),
onUp: () {}, // Block at boundary
onSelect: () => RemoteSessionDialog.show(context),
);
/// Handle key events for the user button in app bar
late final _handleUserKeyEvent = dpadKeyHandler(
onDown: _focusContentFromAppBar,
onLeft: () => _companionRemoteButtonFocusNode.requestFocus(),
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onSelect: () {
final userProvider = context.read<UserProfileProvider>();
_showUserMenu(context, userProvider);
},
);
@override
void dispose() {
_hiddenLibrariesProvider?.removeListener(_onHiddenLibrariesChanged);
@@ -379,14 +294,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_heroController.dispose();
_scrollController.dispose();
_heroFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
_watchTogetherButtonFocusNode.dispose();
_companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange);
_companionRemoteButtonFocusNode.dispose();
_userButtonFocusNode.removeListener(_onUserFocusChange);
_userButtonFocusNode.dispose();
super.dispose();
}
@@ -849,7 +756,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Show user menu programmatically (for D-pad select)
void _showUserMenu(BuildContext context, UserProfileProvider userProvider) {
final RenderBox? button = _userButtonFocusNode.context?.findRenderObject() as RenderBox?;
final actionBar = _actionBarKey.currentState;
if (actionBar == null) return;
final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1);
final RenderBox? button = lastNode.context?.findRenderObject() as RenderBox?;
if (button == null) return;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox;
@@ -869,12 +779,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [AppIcon(Symbols.people_rounded, fill: 1), SizedBox(width: 8), Text(t.discover.switchProfile)],
children: [
AppIcon(Symbols.people_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.discover.switchProfile),
],
),
),
PopupMenuItem(
value: 'logout',
child: Row(children: [AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), Text(t.common.logout)]),
child: Row(
children: [AppIcon(Symbols.logout_rounded, fill: 1), const SizedBox(width: 8), Text(t.common.logout)],
),
),
],
).then((value) {
@@ -916,171 +832,149 @@ class _DiscoverScreenState extends State<DiscoverScreen>
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
const Spacer(),
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white),
onPressed: _loadContent,
),
),
),
// Watch Together button
Consumer<WatchTogetherProvider>(
builder: (context, watchTogether, child) {
return Focus(
focusNode: _watchTogetherButtonFocusNode,
onKeyEvent: _handleWatchTogetherKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.group_rounded,
fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
Consumer2<WatchTogetherProvider, CompanionRemoteProvider>(
builder: (context, watchTogether, companionRemote, _) {
final isDesktop = PlatformDetector.isDesktop(context);
final userProvider = context.watch<UserProfileProvider>();
return FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: _navigateToSidebar,
onNavigateDown: _focusContentFromAppBar,
actions: [
FocusableAction(icon: Symbols.refresh_rounded, iconColor: Colors.white, onPressed: _loadContent),
// Watch Together
FocusableAction(
onPressed: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.group_rounded,
fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
),
tooltip: 'Watch Together',
),
onPressed: () =>
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
tooltip: 'Watch Together',
),
// Badge showing participant count when in session
if (watchTogether.isInSession && watchTogether.participantCount > 1)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Text(
'${watchTogether.participantCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 10,
fontWeight: FontWeight.bold,
if (watchTogether.isInSession && watchTogether.participantCount > 1)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Text(
'${watchTogether.participantCount}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
],
),
),
),
);
},
),
// Companion Remote button
Consumer<CompanionRemoteProvider>(
builder: (context, companionRemote, child) {
final isDesktop = PlatformDetector.isDesktop(context);
final hasDpadNav = isDesktop || PlatformDetector.isTV();
return Focus(
focusNode: hasDpadNav ? _companionRemoteButtonFocusNode : null,
onKeyEvent: hasDpadNav ? _handleCompanionRemoteKeyEvent : null,
child: Container(
decoration: BoxDecoration(
color: hasDpadNav && _isCompanionRemoteFocused
? Colors.white.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.phone_android_rounded,
fill: companionRemote.isConnected ? 1 : 0,
color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white,
),
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen()));
}
},
tooltip: t.companionRemote.title,
),
// Badge showing connection status
if (companionRemote.isConnected)
Positioned(
top: 6,
right: 6,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
),
),
),
],
),
),
);
},
),
Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
return Focus(
focusNode: _userButtonFocusNode,
onKeyEvent: _handleUserKeyEvent,
child: DecoratedBox(
decoration: BoxDecoration(
color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
// Companion Remote
FocusableAction(
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
);
}
},
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
child: Stack(
children: [
IconButton(
icon: AppIcon(
Symbols.phone_android_rounded,
fill: companionRemote.isConnected ? 1 : 0,
color: companionRemote.isConnected
? Theme.of(context).colorScheme.primary
: Colors.white,
),
onPressed: () {
if (isDesktop) {
RemoteSessionDialog.show(context);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
);
}
},
tooltip: t.companionRemote.title,
),
if (companionRemote.isConnected)
Positioned(
top: 6,
right: 6,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)),
),
),
),
],
),
),
// User menu
FocusableAction(
onPressed: () => _showUserMenu(context, userProvider),
child: PopupMenuButton<String>(
icon: userProvider.currentUser?.thumb != null
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
onSelected: (value) {
if (value == 'switch_profile') {
_handleSwitchProfile(context);
} else if (value == 'logout') {
_handleLogout();
}
},
itemBuilder: (context) => [
if (userProvider.hasMultipleUsers)
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
AppIcon(Symbols.people_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.discover.switchProfile),
],
),
),
PopupMenuItem(
value: 'switch_profile',
value: 'logout',
child: Row(
children: [
AppIcon(Symbols.people_rounded, fill: 1),
SizedBox(width: 8),
Text(t.discover.switchProfile),
AppIcon(Symbols.logout_rounded, fill: 1),
const SizedBox(width: 8),
Text(t.common.logout),
],
),
),
PopupMenuItem(
value: 'logout',
child: Row(
children: [
AppIcon(Symbols.logout_rounded, fill: 1),
SizedBox(width: 8),
Text(t.common.logout),
],
),
),
],
],
),
),
),
],
);
},
),
@@ -1215,11 +1109,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
SizedBox(height: 16),
const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)),
],
),
),
@@ -1264,7 +1158,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
},
itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]);
return _buildHeroItem(_onDeck[index], heroHeight);
},
),
// Bottom gradient that extends past hero bounds to ensure seamless blend
@@ -1380,7 +1274,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
);
}
Widget _buildHeroItem(PlexMetadata heroItem) {
Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) {
final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.title;
final screenWidth = MediaQuery.of(context).size.width;
@@ -1406,7 +1300,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
clipBehavior: Clip.none,
children: [
// Background Image with fade/zoom animation and parallax
if (heroItem.art != null || heroItem.grandparentArt != null)
if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null)
ClipRect(
child: AnimatedBuilder(
animation: _scrollController,
@@ -1429,9 +1323,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final client = _getClientForItem(heroItem);
final mediaQuery = MediaQuery.of(context);
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
final containerAspect = screenWidth / heroHeight;
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: heroItem.art ?? heroItem.grandparentArt,
thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt,
maxWidth: mediaQuery.size.width,
maxHeight: mediaQuery.size.height * 0.7,
devicePixelRatio: dpr,
@@ -1692,7 +1587,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
] else
Text(
t.common.play,
style: TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
),
],
),
+14 -124
View File
@@ -1,26 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import '../providers/settings_provider.dart';
import '../utils/grid_size_calculator.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
/// Configuration for app bar buttons
class AppBarButtonConfig {
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
final Color? color;
const AppBarButtonConfig({required this.icon, required this.tooltip, required this.onPressed, this.color});
}
/// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
///
@@ -29,36 +16,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
// Scroll controller for scrolling to top when app bar is focused
final ScrollController scrollController = ScrollController();
// App bar focus nodes
final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play');
final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle');
final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete');
// Action bar key for accessing focus nodes
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
// App bar focus state
bool isAppBarFocused = false;
int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons)
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
bool backHandledByKeyEvent = false;
/// Number of app bar buttons (override if different from 3)
int get appBarButtonCount => 3;
/// Called when items are available and we want to check if focus should be set
bool get hasItems;
/// Called to get the list of app bar button configurations
List<AppBarButtonConfig> getAppBarButtons();
/// Called to get the list of app bar action configurations
List<FocusableAction> getAppBarActions();
/// Dispose focus-related resources. Call this from your dispose() method.
void disposeFocusResources() {
scrollController.dispose();
playButtonFocusNode.dispose();
shuffleButtonFocusNode.dispose();
deleteButtonFocusNode.dispose();
firstItemFocusNode.dispose();
disposeGridFocusNodes();
}
@@ -67,9 +45,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
void navigateToAppBar() {
setState(() {
isAppBarFocused = true;
appBarFocusedButton = 0;
});
_focusAppBarButton(0);
actionBarKey.currentState?.getFocusNode(0).requestFocus();
// Scroll to top to show the app bar
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
@@ -115,103 +92,16 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
}
}
/// Focus a specific app bar button by index
void _focusAppBarButton(int index) {
switch (index) {
case 0:
playButtonFocusNode.requestFocus();
break;
case 1:
shuffleButtonFocusNode.requestFocus();
break;
case 2:
deleteButtonFocusNode.requestFocus();
break;
}
}
/// Handle key events when app bar is focused
KeyEventResult handleAppBarKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final maxButton = appBarButtonCount - 1;
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
if (backResult != KeyEventResult.ignored) {
return backResult;
}
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key.isLeftKey && appBarFocusedButton > 0) {
setState(() => appBarFocusedButton--);
_focusAppBarButton(appBarFocusedButton);
return KeyEventResult.handled;
}
if (key.isRightKey && appBarFocusedButton < maxButton) {
setState(() => appBarFocusedButton++);
_focusAppBarButton(appBarFocusedButton);
return KeyEventResult.handled;
}
if (key.isDownKey) {
// Return focus to grid
navigateToGrid();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
final buttons = getAppBarButtons();
if (appBarFocusedButton < buttons.length) {
buttons[appBarFocusedButton].onPressed();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Build focusable app bar action widgets
List<Widget> buildFocusableAppBarActions() {
final colorScheme = Theme.of(context).colorScheme;
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final buttons = getAppBarButtons();
return buttons.asMap().entries.map((entry) {
final index = entry.key;
final config = entry.value;
final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index;
FocusNode focusNode;
switch (index) {
case 0:
focusNode = playButtonFocusNode;
break;
case 1:
focusNode = shuffleButtonFocusNode;
break;
case 2:
focusNode = deleteButtonFocusNode;
break;
default:
focusNode = FocusNode();
}
return Focus(
focusNode: focusNode,
onKeyEvent: handleAppBarKeyEvent,
child: Container(
decoration: isFocused
? BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(20)),
)
: null,
child: IconButton(
icon: AppIcon(config.icon, fill: 1),
tooltip: config.tooltip,
onPressed: config.onPressed,
color: config.color,
),
),
);
}).toList();
return [
FocusableActionBar(
key: actionBarKey,
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
actions: getAppBarActions(),
),
];
}
/// Auto-focus first item after load if in keyboard mode.
+12 -47
View File
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../services/plex_client.dart';
@@ -15,11 +14,8 @@ import '../widgets/focusable_media_card.dart';
import '../widgets/media_grid_delegate.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/overlay_sheet.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart';
import 'libraries/sort_bottom_sheet.dart';
import 'libraries/state_messages.dart';
@@ -48,7 +44,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
String? _errorMessage;
late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item');
late final FocusNode _sortButtonFocusNode = FocusNode(debugLabel: 'hub_detail_sort');
final _actionBarKey = GlobalKey<FocusableActionBarState>();
bool _isAppBarFocused = false;
bool _backHandledByKeyEvent = false;
@@ -63,7 +59,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
void initState() {
super.initState();
_sortButtonFocusNode.addListener(_onSortButtonFocusChange);
// Start with items already loaded in the hub
_items = widget.hub.items;
_filteredItems = widget.hub.items;
@@ -84,23 +79,11 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
void dispose() {
_sortButtonFocusNode.removeListener(_onSortButtonFocusChange);
_firstItemFocusNode.dispose();
_sortButtonFocusNode.dispose();
disposeGridFocusNodes();
super.dispose();
}
void _onSortButtonFocusChange() {
if (!mounted) return;
final hasFocus = _sortButtonFocusNode.hasFocus;
if (hasFocus && !_isAppBarFocused) {
setState(() => _isAppBarFocused = true);
} else if (!hasFocus && _isAppBarFocused) {
setState(() => _isAppBarFocused = false);
}
}
void _focusGrid() {
if (_filteredItems.isEmpty) return;
final targetIndex =
@@ -114,7 +97,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
void _navigateToAppBar() {
setState(() => _isAppBarFocused = true);
_sortButtonFocusNode.requestFocus();
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
}
void _handleBackFromContent() {
@@ -122,24 +105,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
_navigateToAppBar();
}
KeyEventResult _handleSortButtonKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
if (backResult != KeyEventResult.ignored) return backResult;
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key.isDownKey) {
_focusGrid();
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_showSortBottomSheet();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Future<void> _loadSorts() async {
try {
@@ -315,7 +280,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
@override
Widget build(BuildContext context) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
final sortButtonFocused = isKeyboardMode && _isAppBarFocused;
return PopScope(
canPop: !isKeyboardMode || _isAppBarFocused,
@@ -336,16 +300,17 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable, Gri
title: Text(widget.hub.title),
pinned: true,
actions: [
Focus(
focusNode: _sortButtonFocusNode,
onKeyEvent: _handleSortButtonKeyEvent,
child: Container(
decoration: FocusTheme.focusBackgroundDecoration(isFocused: sortButtonFocused, borderRadius: 20),
child: IconButton(
icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort),
FocusableActionBar(
key: _actionBarKey,
onNavigateDown: _focusGrid,
onBack: () => Navigator.pop(context),
actions: [
FocusableAction(
icon: Symbols.swap_vert_rounded,
tooltip: t.libraries.sort,
onPressed: _showSortBottomSheet,
),
),
],
),
],
),
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/plex_first_character.dart';
import '../../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Vertical strip of letters for jumping through sorted library items.
@@ -1,5 +1,5 @@
import '../data/ducet_order.dart';
import '../models/plex_first_character.dart';
import '../../data/ducet_order.dart';
import '../../models/plex_first_character.dart';
/// Shared letter-index mapping logic used by both [AlphaJumpBar] (desktop/tablet/TV)
/// and [AlphaScrollHandle] (phone).
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../models/plex_first_character.dart';
import '../../models/plex_first_character.dart';
import 'alpha_jump_helper.dart';
/// Phone-optimized draggable scroll handle that appears on scroll and shows
+18 -85
View File
@@ -4,9 +4,10 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:dio/dio.dart';
import '../../focus/focus_theme.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../mixins/tab_navigation_mixin.dart';
@@ -127,11 +128,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_playlistsTabChipFocusNode,
];
// App bar action button focus
late FocusNode _editButtonFocusNode;
late FocusNode _refreshButtonFocusNode;
bool _isEditFocused = false;
bool _isRefreshFocused = false;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController();
@@ -141,12 +139,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
super.initState();
initTabNavigation();
// Initialize action button focus nodes
_editButtonFocusNode = FocusNode(debugLabel: 'EditButton');
_refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
_editButtonFocusNode.addListener(_onEditFocusChange);
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
// Initialize with libraries from the provider
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeWithLibraries();
@@ -338,42 +330,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_focusCurrentTab();
}
void _onEditFocusChange() {
if (mounted) {
setState(() => _isEditFocused = _editButtonFocusNode.hasFocus);
}
}
void _onRefreshFocusChange() {
if (mounted) {
setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
}
/// Handle key events for the edit button in app bar
late final _handleEditKeyEvent = dpadKeyHandler(
onLeft: () => getTabChipFocusNode(3).requestFocus(),
onRight: () => _refreshButtonFocusNode.requestFocus(),
onDown: _focusCurrentTab,
onUp: () {}, // Block at boundary
onSelect: _showLibraryManagementSheet,
);
/// Handle key events for the refresh button in app bar
late final _handleRefreshKeyEvent = dpadKeyHandler(
onLeft: () {
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
getTabChipFocusNode(3).requestFocus();
}
},
onRight: () {}, // Block at boundary
onUp: () {}, // Block at boundary
onDown: _focusCurrentTab,
onSelect: _refreshCurrentTab,
);
@override
void dispose() {
@@ -383,10 +339,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_browseTabChipFocusNode.dispose();
_collectionsTabChipFocusNode.dispose();
_playlistsTabChipFocusNode.dispose();
_editButtonFocusNode.removeListener(_onEditFocusChange);
_editButtonFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
@@ -524,7 +476,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
) async {
while (_hasMoreItems && requestId == _requestId) {
try {
final items = await client.getLibraryContent(
final result = await client.getLibraryContent(
library.key,
start: _currentPage * _pageSize,
size: _pageSize,
@@ -533,7 +485,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
// Tag items with server info for multi-server support
final taggedItems = items
final taggedItems = result.items
.map((item) => item.copyWith(serverId: library.serverId, serverName: library.serverName))
.toList();
@@ -935,13 +887,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
getTabChipFocusNode(newIndex).requestFocus();
}
: () {
// Navigate to first action button (edit if libraries exist, else refresh)
final librariesProvider = context.read<LibrariesProvider>();
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
_refreshButtonFocusNode.requestFocus();
}
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
},
onNavigateDown: _focusCurrentTabFromTabBar,
onBack: onTabBarBack,
@@ -1048,36 +994,23 @@ class _LibrariesScreenState extends State<LibrariesScreen>
shadowColor: Colors.transparent,
scrolledUnderElevation: 0,
actions: [
if (allLibraries.isNotEmpty)
Focus(
focusNode: _editButtonFocusNode,
onKeyEvent: _handleEditKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.edit_rounded, fill: 1),
FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
if (allLibraries.isNotEmpty)
FocusableAction(
icon: Symbols.edit_rounded,
tooltip: t.libraries.manageLibraries,
onPressed: _showLibraryManagementSheet,
),
),
),
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _refreshCurrentTab,
),
),
],
),
],
),
@@ -76,7 +76,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Focus management
bool _hasLoadedData = false;
bool _hasFocused = false;
@protected
bool hasFocused = false;
// Getters for subclasses
List<T> get items => _items;
@@ -122,7 +123,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Reload if library changed
if (oldWidget.library.globalKey != widget.library.globalKey) {
// Reset focus state for new library
_hasFocused = false;
hasFocused = false;
_hasLoadedData = false;
// Immediately clear stale data before async load
_items = [];
@@ -164,8 +165,8 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (widget.suppressAutoFocus) return;
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) {
_hasFocused = true;
if (widget.isActive && _hasLoadedData && !hasFocused && _items.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
focusFirstItem();
+303 -118
View File
@@ -14,10 +14,11 @@ import '../../../providers/settings_provider.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../utils/layout_constants.dart';
import '../../../widgets/alpha_jump_bar.dart';
import '../../../widgets/alpha_jump_helper.dart';
import '../../../widgets/alpha_scroll_handle.dart';
import '../alpha_jump_bar.dart';
import '../alpha_jump_helper.dart';
import '../alpha_scroll_handle.dart';
import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/media_card.dart';
import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/overlay_sheet.dart';
@@ -68,14 +69,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
String? get deletionServerId => widget.library.serverId;
@override
Set<String>? get deletionRatingKeys => items.map((e) => e.ratingKey).toSet();
Set<String>? get deletionRatingKeys => _loadedItems.values.map((e) => e.ratingKey).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (items.isEmpty) return <String>{};
if (_loadedItems.isEmpty) return <String>{};
final keys = <String>{};
for (final item in items) {
for (final item in _loadedItems.values) {
final serverId = item.serverId ?? widget.library.serverId;
if (serverId == null) return null;
keys.add(_toGlobalKey(item.ratingKey, serverId: serverId));
@@ -85,30 +86,30 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
@override
void onDeletionEvent(DeletionEvent event) {
// If we have an item that matches the rating key exactly, then remove it from our list
final index = items.indexWhere((e) => e.ratingKey == event.ratingKey);
if (index != -1) {
// If we have an item that matches the rating key exactly, remove it and rebuild indices
final matchEntry = _loadedItems.entries.where((e) => e.value.ratingKey == event.ratingKey).firstOrNull;
if (matchEntry != null) {
setState(() {
items.removeAt(index);
_removeLoadedItemAndShift(matchEntry.key);
});
return;
}
// If a child item was delete, then update our list to reflect that.
// If a child item was deleted, update our item to reflect that.
// If all children were deleted, remove our item.
// Otherwise, just update the counts.
for (final parentKey in event.parentChain) {
final parentIndex = items.indexWhere((e) => e.ratingKey == parentKey);
if (parentIndex != -1) {
final item = items[parentIndex];
final parentEntry = _loadedItems.entries.where((e) => e.value.ratingKey == parentKey).firstOrNull;
if (parentEntry != null) {
final item = parentEntry.value;
final newLeafCount = (item.leafCount ?? 1) - event.leafCount;
if (newLeafCount <= 0) {
setState(() {
items.removeAt(parentIndex);
_removeLoadedItemAndShift(parentEntry.key);
});
} else {
setState(() {
items[parentIndex] = item.copyWith(leafCount: newLeafCount);
_loadedItems[parentEntry.key] = item.copyWith(leafCount: newLeafCount);
});
}
return;
@@ -116,18 +117,37 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Remove an item at [index] and shift all higher indices down by 1
void _removeLoadedItemAndShift(int index) {
_loadedItems.remove(index);
// Rebuild map with shifted indices for items above the removed one
final shifted = <int, PlexMetadata>{};
for (final entry in _loadedItems.entries) {
if (entry.key < index) {
shifted[entry.key] = entry.value;
} else {
shifted[entry.key - 1] = entry.value;
}
}
_loadedItems.clear();
_loadedItems.addAll(shifted);
_totalSize = (_totalSize - 1).clamp(0, _totalSize);
}
@override
String get focusNodeDebugLabel => 'browse_first_item';
@override
int get itemCount => items.length;
int get itemCount => _totalSize;
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
setState(() {
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) {
items[index] = updatedMetadata;
for (final entry in _loadedItems.entries) {
if (entry.value.ratingKey == ratingKey) {
_loadedItems[entry.key] = updatedMetadata;
break;
}
}
});
}
@@ -162,11 +182,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Timer? _scrollActivityTimer;
// Pagination state
int _currentPage = 0;
bool _hasMoreItems = true;
int _totalSize = 0;
final Map<int, PlexMetadata> _loadedItems = {};
final Set<int> _loadingRanges = {};
CancelToken? _cancelToken;
int _requestId = 0;
static const int _pageSize = 500;
static const int _fetchSize = 200;
Timer? _scrollIdleTimer;
// Focus nodes for filter chips
final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip');
@@ -186,6 +208,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void dispose() {
_cancelToken?.cancel();
_scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel();
_scrollController.removeListener(_onScrollChanged);
_scrollController.dispose();
_groupingChipFocusNode.dispose();
@@ -196,6 +219,18 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
super.dispose();
}
// Override tryFocus to use _loadedItems instead of base class items list
@override
void tryFocus() {
if (widget.suppressAutoFocus) return;
if (widget.isActive && hasLoadedData && !hasFocused && _loadedItems.isNotEmpty) {
hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) focusFirstItem();
});
}
}
// Override loadData to use our custom _loadContent
@override
Future<List<PlexMetadata>> loadData() async {
@@ -240,11 +275,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (items.isNotEmpty) {
if (_loadedItems.isNotEmpty) {
// Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt.
void request() {
if (mounted && items.isNotEmpty && !firstItemFocusNode.hasFocus) {
if (mounted && _loadedItems.isNotEmpty && !firstItemFocusNode.hasFocus) {
firstItemFocusNode.requestFocus();
}
}
@@ -268,7 +303,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Cancel any pending request
_cancelToken?.cancel();
_cancelToken = CancelToken();
final currentRequestId = ++_requestId;
// Use a generation counter for the filter/sort loading phase
final generation = ++_requestId;
// Extract context dependencies before async gap - use server-specific client
final client = getClientForLibrary();
@@ -277,8 +313,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
isLoading = true;
errorMessage = null;
items = [];
_currentPage = 0;
_hasMoreItems = true;
_totalSize = 0;
_loadedItems.clear();
_loadingRanges.clear();
// Clear filter/sort state while loading to prevent showing stale options
_filters = [];
_sortOptions = [];
@@ -303,8 +340,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final savedSort = storage.getLibrarySort(widget.library.globalKey);
final savedGrouping = storage.getLibraryGrouping(widget.library.globalKey);
// Check if request was cancelled
if (currentRequestId != _requestId) return;
// Check if request was superseded
if (generation != _requestId) return;
if (!mounted) return;
setState(() {
@@ -327,62 +364,64 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
});
// Load items and first characters in parallel
// _loadItems manages its own requestId internally
await Future.wait([_loadItems(), _loadFirstCharacters()]);
} catch (e) {
_handleLoadError(e, currentRequestId);
if (!mounted) return;
setState(() {
errorMessage = _getErrorMessage(e);
isLoading = false;
});
}
}
Future<void> _loadItems({bool loadMore = false}) async {
if (loadMore && isLoading) return;
/// Build the filter params map for API calls
Map<String, String> _buildFilterParams() {
final filterParams = Map<String, String>.from(_selectedFilters);
if (!loadMore) {
_currentPage = 0;
_hasMoreItems = true;
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
}
if (!_hasMoreItems) return;
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
}
final currentRequestId = _requestId;
return filterParams;
}
Future<void> _loadItems() async {
final currentRequestId = ++_requestId;
_cancelToken?.cancel();
_cancelToken = CancelToken();
setState(() {
isLoading = true;
if (!loadMore) {
items = [];
// Increment content version when loading fresh content
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(items.length);
}
items = [];
_totalSize = 0;
_loadedItems.clear();
_loadingRanges.clear();
// Increment content version when loading fresh content
// This invalidates the last focused index
gridContentVersion++;
cleanupGridFocusNodes(0);
});
try {
// Use server-specific client for this library
final client = getClientForLibrary();
// Build filter params
final filterParams = Map<String, String>.from(_selectedFilters);
// Add grouping type filter (but not for 'all' or 'folders')
if (_selectedGrouping != 'all' && _selectedGrouping != 'folders') {
final typeId = _getGroupingTypeId();
if (typeId.isNotEmpty) {
filterParams['type'] = typeId;
}
}
// Add sort
if (_selectedSort != null) {
filterParams['sort'] = _selectedSort!.getSortKey(descending: _isSortDescending);
}
final filterParams = _buildFilterParams();
// Items are automatically tagged with server info by PlexClient
final loadedItems = await client.getLibraryContent(
final result = await client.getLibraryContent(
widget.library.key,
start: _currentPage * _pageSize,
size: _pageSize,
start: 0,
size: _fetchSize,
filters: filterParams,
cancelToken: _cancelToken,
);
@@ -391,33 +430,81 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
if (!mounted) return;
setState(() {
if (loadMore) {
items.addAll(loadedItems);
} else {
items = loadedItems;
_totalSize = result.totalSize;
for (var i = 0; i < result.items.length; i++) {
_loadedItems[i] = result.items[i];
}
_hasMoreItems = loadedItems.length >= _pageSize;
_currentPage++;
isLoading = false;
});
// On initial load (not pagination), mark data as loaded and try to focus
if (!loadMore) {
hasLoadedData = true;
tryFocus();
hasLoadedData = true;
tryFocus();
// Notify parent
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
});
}
// Notify parent
if (widget.onDataLoaded != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onDataLoaded!();
});
}
} catch (e) {
_handleLoadError(e, currentRequestId);
}
}
/// Fetch a range of items from the API and store them in the sparse map.
/// After a successful fetch, re-checks for remaining gaps in the visible range.
Future<void> _fetchRange(int start, int size) async {
// Clamp to totalSize
if (start >= _totalSize) return;
final clampedSize = size.clamp(0, _totalSize - start);
if (clampedSize == 0) return;
// Deduplicate: track every index in-flight to prevent overlapping fetches
final indices = List.generate(clampedSize, (i) => start + i);
if (indices.every((i) => _loadingRanges.contains(i) || _loadedItems.containsKey(i))) return;
_loadingRanges.addAll(indices);
final currentRequestId = _requestId;
try {
final client = getClientForLibrary();
final filterParams = _buildFilterParams();
final result = await client.getLibraryContent(
widget.library.key,
start: start,
size: clampedSize,
filters: filterParams,
cancelToken: _cancelToken,
);
if (currentRequestId != _requestId || !mounted) return;
setState(() {
for (var i = 0; i < result.items.length; i++) {
_loadedItems[start + i] = result.items[i];
}
// Update totalSize in case it changed (e.g., items added/removed on server)
if (result.totalSize != _totalSize) {
_totalSize = result.totalSize;
}
});
// Re-check for remaining gaps in the visible range after this fetch
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && currentRequestId == _requestId) {
_loadVisibleRange();
}
});
} catch (e) {
// Silently ignore fetch errors for background range loads
// (the initial load handles errors with UI feedback)
if (e is DioException && e.type == DioExceptionType.cancel) return;
} finally {
_loadingRanges.removeAll(indices);
}
}
void _handleLoadError(dynamic error, int currentRequestId) {
if (currentRequestId != _requestId) return;
@@ -617,9 +704,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
return;
}
if (items.isEmpty) return;
if (_totalSize == 0) return;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < items.length ? lastFocusedGridIndex! : 0;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < _totalSize && _loadedItems.containsKey(lastFocusedGridIndex!) ? lastFocusedGridIndex! : 0;
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
if (targetIndex == 0) {
@@ -634,10 +721,33 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// FocusNode detached), so we target the last-column item in the first
/// visible row — the grid cell closest to the alpha bar.
void _navigateToGridNearScroll() {
if (items.isEmpty || _currentColumnCount < 1) return;
if (_totalSize == 0 || _currentColumnCount < 1) return;
final row = _currentFirstVisibleIndex ~/ _currentColumnCount;
final targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, items.length - 1);
var targetIndex = ((row + 1) * _currentColumnCount - 1).clamp(0, _totalSize - 1);
// Find nearest loaded item — skeleton cards have no FocusNode
if (!_loadedItems.containsKey(targetIndex)) {
// Search backwards first (items above are more likely visible)
int? found;
for (var i = targetIndex - 1; i >= 0; i--) {
if (_loadedItems.containsKey(i)) {
found = i;
break;
}
}
// Then search forwards
if (found == null) {
for (var i = targetIndex + 1; i < _totalSize; i++) {
if (_loadedItems.containsKey(i)) {
found = i;
break;
}
}
}
if (found == null) return;
targetIndex = found;
}
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
@@ -676,6 +786,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
bool get _shouldShowAlphaJumpBar {
if (_selectedGrouping == 'folders') return false;
if (_firstCharacters.isEmpty) return false;
if (_firstCharacters.length < 6 || _alphaHelper.totalItemCount < 80) return false;
// Show when no sort is selected (default is titleSort) or when explicitly sorting by title
final sortKey = _selectedSort?.key ?? '';
return sortKey.isEmpty || sortKey.startsWith('titleSort');
@@ -710,11 +821,17 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
}
}
/// Track scroll position to highlight the current letter in the jump bar
/// Track scroll position and trigger debounced range loading.
void _onScrollChanged() {
// Debounced scroll-idle handler: load visible range when scrolling settles
_scrollIdleTimer?.cancel();
_scrollIdleTimer = Timer(const Duration(milliseconds: 200), () {
if (mounted) _loadVisibleRange();
});
if (!_shouldShowAlphaJumpBar || _currentColumnCount < 1) return;
// During a jump animation, skip all processing to avoid flashing.
// During a jump animation, skip alpha bar processing to avoid flashing.
if (_isJumpScrolling) return;
// If pinned from a completed jump, the next scroll event must be
@@ -733,7 +850,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final firstInRow = _itemIndexFromScrollOffset(offset);
// Use the last item in the first visible row so the highlighted letter
// updates as soon as items with a new letter appear in that row.
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, items.length - 1);
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
final lastInRow = (firstInRow + _currentColumnCount - 1).clamp(0, maxIndex);
if (lastInRow != _currentFirstVisibleIndex) {
setState(() => _currentFirstVisibleIndex = lastInRow);
}
@@ -754,7 +872,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// First visible row = (offset + chipsBarHeight - effectiveTopPadding) / rowHeight
final contentOffset = (offset + _chipsBarHeight - _effectiveTopPadding).clamp(0.0, double.infinity);
final row = (contentOffset / rowHeight).floor();
return (row * _currentColumnCount).clamp(0, items.length - 1);
final maxIndex = _totalSize > 0 ? _totalSize - 1 : 0;
return (row * _currentColumnCount).clamp(0, maxIndex);
}
/// Scroll to the item at [targetIndex], loading more pages if necessary.
@@ -766,13 +885,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_isJumpScrolling = true;
_hasJumpPin = true;
setState(() => _currentFirstVisibleIndex = targetIndex);
final clamped = targetIndex.clamp(0, _totalSize > 0 ? _totalSize - 1 : 0);
setState(() => _currentFirstVisibleIndex = clamped);
if (targetIndex < items.length) {
_scrollToItemIndex(targetIndex);
} else {
_loadUntilIndex(targetIndex);
}
_scrollToItemIndex(clamped);
}
/// Scroll the grid so that [index] is visible just below the chips bar
@@ -809,16 +925,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
});
}
/// Load pages until [targetIndex] is loaded, then scroll to it
Future<void> _loadUntilIndex(int targetIndex) async {
while (items.length <= targetIndex && _hasMoreItems) {
await _loadItems(loadMore: true);
}
if (mounted) {
_scrollToItemIndex(targetIndex.clamp(0, items.length - 1));
}
}
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
@@ -876,13 +982,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
/// Builds the scrollable content (grid/list) with pagination support
/// Builds the scrollable content (grid/list) with scroll-idle loading
Widget _buildScrollableContent() {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && !isLoading) {
_loadItems(loadMore: true);
}
// Track scroll activity for phone scroll handle
if (notification is ScrollStartNotification) {
if (!_isScrollActive) setState(() => _isScrollActive = true);
@@ -904,6 +1007,48 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
/// Determine the visible range and fetch any unloaded items within it.
/// Covers the full visible area plus a buffer of _fetchSize/2 on each side,
/// then finds the first unloaded contiguous block and fetches it.
void _loadVisibleRange() {
if (_totalSize == 0 || _currentColumnCount < 1 || !_scrollController.hasClients) return;
if (_lastCrossAxisExtent <= 0) return;
final offset = _scrollController.offset;
final viewportHeight = _scrollController.position.viewportDimension;
final firstIndex = _itemIndexFromScrollOffset(offset);
// Calculate how many items fit in the viewport
final itemWidth = _lastCrossAxisExtent / _currentColumnCount;
final itemHeight = itemWidth / GridLayoutConstants.posterAspectRatio;
final rowHeight = itemHeight + GridLayoutConstants.mainAxisSpacing;
if (rowHeight <= 0) return;
final visibleRows = (viewportHeight / rowHeight).ceil() + 1;
final visibleCount = visibleRows * _currentColumnCount;
// Expand the visible range by a buffer on each side
final buffer = _fetchSize ~/ 2;
final rangeStart = (firstIndex - buffer).clamp(0, _totalSize);
final rangeEnd = (firstIndex + visibleCount + buffer).clamp(0, _totalSize);
// Find the first and last unloaded indices in the range
int? fetchStart;
int? fetchEnd;
for (var i = rangeStart; i < rangeEnd; i++) {
if (!_loadedItems.containsKey(i) && !_loadingRanges.contains(i)) {
fetchStart ??= i;
fetchEnd = i + 1;
}
}
if (fetchStart == null || fetchEnd == null) return;
final fetchSize = fetchEnd - fetchStart;
if (fetchSize <= 0) return;
_fetchRange(fetchStart, fetchSize);
}
/// Whether the filters chip is visible
bool get _isFiltersChipVisible => _filters.isNotEmpty && _selectedGrouping != 'folders';
@@ -976,11 +1121,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds content as slivers for the CustomScrollView
List<Widget> _buildContentSlivers() {
if (isLoading && items.isEmpty) {
if (isLoading && _totalSize == 0 && _loadedItems.isEmpty) {
return [const SliverFillRemaining(child: Center(child: CircularProgressIndicator()))];
}
if (errorMessage != null && items.isEmpty) {
if (errorMessage != null && _loadedItems.isEmpty) {
return [
SliverFillRemaining(
child: ErrorStateWidget(
@@ -993,7 +1138,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
];
}
if (items.isEmpty) {
if (_totalSize == 0 && !isLoading) {
return [
SliverFillRemaining(
child: EmptyStateWidget(message: t.libraries.thisLibraryIsEmpty, icon: Symbols.folder_open_rounded),
@@ -1021,7 +1166,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Builds either a sliver list or sliver grid based on the view mode
Widget _buildItemsSliver(BuildContext context, SettingsProvider settingsProvider) {
final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0);
final itemCount = _totalSize;
final isPhone = _isPhone(context);
final topPadding = isPhone ? _gridTopPaddingPhone : _gridTopPadding;
_effectiveTopPadding = topPadding;
@@ -1080,13 +1225,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
required bool isFirstColumn,
bool isLastColumn = false,
}) {
if (index >= items.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
final item = _loadedItems[index];
// Show skeleton placeholder for unloaded items
if (item == null) {
return const _SkeletonCard();
}
final item = items[index];
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
// All other items get managed focus nodes for restoration
@@ -1106,3 +1250,44 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
);
}
}
/// Skeleton placeholder card that matches the poster + title layout of a real media card.
/// Not focusable — dpad focus skips over these.
class _SkeletonCard extends StatelessWidget {
const _SkeletonCard();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster area — matches the Expanded poster in _buildGridCard
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: const SkeletonLoader(child: SizedBox.expand()),
),
),
const SizedBox(height: 4),
// Title bar
SkeletonLoader(
borderRadius: BorderRadius.circular(4),
child: const SizedBox(height: 13, width: double.infinity),
),
const SizedBox(height: 3),
// Subtitle bar
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: 0.6,
child: SkeletonLoader(
borderRadius: BorderRadius.circular(4),
child: const SizedBox(height: 11),
),
),
],
),
);
}
}
+12 -50
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../focus/dpad_navigator.dart';
import '../../focus/focusable_action_bar.dart';
import '../../i18n/strings.g.dart';
import '../../models/livetv_channel.dart';
import '../../models/livetv_dvr.dart';
@@ -31,9 +31,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final _guideTabKey = GlobalKey<GuideTabState>();
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
// App bar action button focus
final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
bool _isRefreshFocused = false;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
List<LiveTvChannel> _channels = [];
bool _isLoading = true;
@@ -47,7 +46,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.initState();
suppressAutoFocus = true;
initTabNavigation();
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_loadChannels();
}
@@ -55,15 +53,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
void dispose() {
_guideTabFocusNode.dispose();
_whatsOnTabFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
void _onRefreshFocusChange() {
if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus);
}
@override
void onTabChanged() {
@@ -208,34 +201,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
@override
void focusActiveTabIfReady() => _focusCurrentTab();
// ---------------------------------------------------------------------------
// Action button key handlers
// ---------------------------------------------------------------------------
KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) {
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isLeftKey) {
getTabChipFocusNode(tabCount - 1).requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey) {
return KeyEventResult.handled;
}
if (key.isDownKey) {
_focusCurrentTab();
return KeyEventResult.handled;
}
if (key.isUpKey) {
return KeyEventResult.handled;
}
if (key.isSelectKey) {
_loadChannels();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
// ---------------------------------------------------------------------------
// Tab chips
@@ -276,7 +241,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
});
getTabChipFocusNode(newIndex).requestFocus();
}
: () => _refreshButtonFocusNode.requestFocus(),
: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack,
);
@@ -303,20 +268,17 @@ class _LiveTvScreenState extends State<LiveTvScreen>
)
: Text(t.liveTv.title),
actions: [
Focus(
focusNode: _refreshButtonFocusNode,
onKeyEvent: _handleRefreshKeyEvent,
child: Container(
decoration: BoxDecoration(
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: IconButton(
icon: const AppIcon(Symbols.refresh_rounded),
FocusableActionBar(
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.liveTv.reloadGuide,
onPressed: _loadChannels,
),
),
],
),
],
),
+1 -1
View File
@@ -533,7 +533,7 @@ class GuideTabState extends State<GuideTab> {
children: [
Row(
children: [
SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
const SizedBox(width: _channelColumnWidth, height: _timeHeaderHeight),
Expanded(
child: SingleChildScrollView(
controller: _headerHorizontalController,
+20 -7
View File
@@ -65,6 +65,7 @@ class MediaDetailScreen extends StatefulWidget {
class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAware, DeletionAware {
List<PlexMetadata> _seasons = [];
bool _isLoadingSeasons = false;
Completer<void>? _seasonsCompleter;
PlexMetadata? _fullMetadata;
PlexMetadata? _onDeckEpisode;
PlexVideoPlaybackData? _playbackData;
@@ -713,11 +714,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
const SizedBox(width: 12),
IconButton.filledTonal(
onPressed: () async {
final result = await Navigator.push(
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: metadata)),
);
if (result == true && mounted) {
if (mounted) {
_loadFullMetadata();
}
},
@@ -1025,6 +1026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
}
Future<void> _loadSeasons() async {
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
});
@@ -1048,11 +1050,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
setState(() {
_isLoadingSeasons = false;
});
} finally {
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
}
/// Load seasons from downloaded episodes (offline mode)
void _loadSeasonsFromDownloads() {
_seasonsCompleter = Completer<void>();
setState(() {
_isLoadingSeasons = true;
});
@@ -1087,6 +1094,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
_seasons = seasons;
_isLoadingSeasons = false;
});
if (!(_seasonsCompleter?.isCompleted ?? true)) {
_seasonsCompleter?.complete();
}
}
/// Load extras (trailers, behind-the-scenes, etc.)
@@ -1654,8 +1664,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
}
// Wait for seasons to finish loading if they're currently loading
while (_isLoadingSeasons) {
await Future.delayed(const Duration(milliseconds: 100));
if (_isLoadingSeasons && _seasonsCompleter != null) {
await _seasonsCompleter!.future.timeout(const Duration(seconds: 10), onTimeout: () {});
}
if (!mounted) return;
@@ -1853,14 +1863,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
SizedBox(
height: headerHeight,
width: double.infinity,
child: metadata.art != null
child: (metadata.art != null || metadata.backgroundSquare != null)
? Builder(
builder: (context) {
final containerAspect = size.width / headerHeight;
final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect);
// Check for offline local file first
if (widget.isOffline && widget.metadata.serverId != null) {
final localPath = context.read<DownloadProvider>().getArtworkLocalPath(
widget.metadata.serverId!,
metadata.art,
heroArtPath,
);
if (localPath != null && File(localPath).existsSync()) {
return Image.file(
@@ -1879,7 +1892,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
final dpr = PlexImageHelper.effectiveDevicePixelRatio(context);
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: metadata.art,
thumbPath: heroArtPath,
maxWidth: mediaQuery.size.width,
maxHeight: mediaQuery.size.height * 0.6,
devicePixelRatio: dpr,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_action_bar.dart';
import '../../services/plex_client.dart';
import '../../services/play_queue_launcher.dart';
import '../../models/plex_playlist.dart';
@@ -51,33 +52,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
bool get hasItems => items.isNotEmpty;
@override
int get appBarButtonCount {
int count = 0;
if (items.isNotEmpty) count += 2; // play + shuffle
if (!widget.playlist.smart) count += 1; // delete
return count;
}
@override
List<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
if (items.isNotEmpty) {
buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems));
buttons.add(
AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
);
}
if (!widget.playlist.smart) {
buttons.add(
AppBarButtonConfig(
icon: Symbols.delete_rounded,
tooltip: t.playlists.delete,
onPressed: _deletePlaylist,
color: Colors.red,
),
);
}
return buttons;
List<FocusableAction> getAppBarActions() {
return [
if (items.isNotEmpty) ...[
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
],
if (!widget.playlist.smart)
FocusableAction(icon: Symbols.delete_rounded, tooltip: t.playlists.delete, onPressed: _deletePlaylist, iconColor: Colors.red),
];
}
// Focus management for regular (non-smart) reorderable lists
+4 -4
View File
@@ -365,7 +365,7 @@ class _TvPinInputState extends State<_TvPinInput> {
_digits[index] = digit;
_activeIndex = index;
_mobileControllers[index].text = digit.toString();
_mobileControllers[index].selection = TextSelection.collapsed(offset: 1);
_mobileControllers[index].selection = const TextSelection.collapsed(offset: 1);
});
if (index < 3) {
@@ -436,12 +436,12 @@ class _TvPinInputState extends State<_TvPinInput> {
maxLength: 2, // allow overwrite
obscureText: true,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
decoration: InputDecoration(
decoration: const InputDecoration(
counterText: '',
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
borderRadius: BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)),
),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _onMobileDigitChanged(i, value),
+6 -6
View File
@@ -235,16 +235,16 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
: null,
filled: true,
fillColor: Theme.of(context).colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(100)),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(100)),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+27 -6
View File
@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -14,6 +15,8 @@ import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../models/download_models.dart';
import '../providers/download_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/content_utils.dart';
import '../services/download_storage_service.dart';
import '../widgets/collapsible_text.dart';
import '../widgets/plex_optimized_image.dart';
@@ -58,7 +61,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
bool _suppressNextBackKeyUp = false;
bool _routeSubscribed = false;
String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey);
String _toGlobalKey(String ratingKey, {String? serverId}) =>
buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey);
// WatchStateAware: watch all episode ratingKeys
@override
@@ -363,14 +367,18 @@ class _EpisodeCardState extends State<_EpisodeCard> {
);
return Row(
children: [
if (widget.episode.duration != null) Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.duration != null)
Text(formatDurationTimestamp(Duration(milliseconds: widget.episode.duration!)), style: mutedStyle),
if (widget.episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(widget.episode.originallyAvailableAt!), style: mutedStyle),
],
if (widget.episode.userRating != null && widget.episode.userRating! > 0) ...[
dot,
Padding(padding: const EdgeInsets.only(top: 2), child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber)),
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber),
),
const SizedBox(width: 2),
Text(
(widget.episode.userRating! / 2) == (widget.episode.userRating! / 2).truncateToDouble()
@@ -385,6 +393,9 @@ class _EpisodeCardState extends State<_EpisodeCard> {
@override
Widget build(BuildContext context) {
final hideSpoilers = context.watch<SettingsProvider>().hideSpoilers;
final shouldBlur = hideSpoilers && widget.episode.shouldHideSpoiler;
// Hide progress when offline (not tracked)
final hasProgress =
!widget.isOffline &&
@@ -432,7 +443,17 @@ class _EpisodeCardState extends State<_EpisodeCard> {
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6)),
child: AspectRatio(aspectRatio: 16 / 9, child: _buildEpisodeThumbnail()),
child: AspectRatio(
aspectRatio: 16 / 9,
child: shouldBlur
? ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: _buildEpisodeThumbnail(),
),
)
: _buildEpisodeThumbnail(),
),
),
// Play overlay
@@ -636,8 +657,8 @@ class _EpisodeCardState extends State<_EpisodeCard> {
},
),
// Summary
if (widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
// Summary (hidden when spoiler protection is active)
if (!shouldBlur && widget.episode.summary != null && widget.episode.summary!.isNotEmpty) ...[
const SizedBox(height: 6),
if (PlatformDetector.isTV())
Text(
+115 -163
View File
@@ -2,15 +2,16 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../../focus/focusable_action_bar.dart';
import '../../focus/focusable_button.dart';
import '../../focus/key_event_utils.dart';
import '../../i18n/strings.g.dart';
import '../../utils/app_logger.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/desktop_app_bar.dart';
class LogsScreen extends StatefulWidget {
const LogsScreen({super.key});
@@ -21,6 +22,7 @@ class LogsScreen extends StatefulWidget {
class _LogsScreenState extends State<LogsScreen> {
List<LogEntry> _logs = [];
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -28,6 +30,12 @@ class _LogsScreenState extends State<LogsScreen> {
_logs = MemoryLogOutput.getLogs();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _loadLogs() {
setState(() {
_logs = MemoryLogOutput.getLogs();
@@ -115,7 +123,7 @@ class _LogsScreenState extends State<LogsScreen> {
icon: const Icon(Icons.copy, size: 20),
onPressed: () {
Clipboard.setData(ClipboardData(text: id));
showSuccessSnackBar(ctx, t.messages.logsCopied);
showSuccessSnackBar(context, t.messages.logsCopied);
},
),
],
@@ -156,178 +164,122 @@ class _LogsScreenState extends State<LogsScreen> {
}
}
IconData _getLevelIcon(Level level) {
switch (level) {
case Level.error:
case Level.fatal:
return Symbols.error_rounded;
case Level.warning:
return Symbols.warning_rounded;
case Level.info:
return Symbols.info_rounded;
case Level.debug:
case Level.trace:
return Symbols.bug_report_rounded;
default:
return Symbols.circle_rounded;
}
}
@override
Widget build(BuildContext context) {
return FocusedScrollScaffold(
title: Text(t.screens.logs),
actions: [
IconButton(
icon: const AppIcon(Symbols.refresh_rounded, fill: 1),
onPressed: _loadLogs,
tooltip: t.common.refresh,
),
IconButton(
icon: const AppIcon(Symbols.upload_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
tooltip: t.logs.uploadLogs,
),
IconButton(
icon: const AppIcon(Symbols.content_copy_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
tooltip: t.logs.copyLogs,
),
IconButton(
icon: const AppIcon(Symbols.delete_outline_rounded, fill: 1),
onPressed: _logs.isNotEmpty ? _clearLogs : null,
tooltip: t.logs.clearLogs,
),
],
slivers: [
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final log = _logs[index];
return _LogEntryCard(
log: log,
formatTime: _formatTime,
levelColor: _getLevelColor(log.level),
levelIcon: _getLevelIcon(log.level),
);
}, childCount: _logs.length),
),
),
],
void _scroll(double delta) {
final pos = _scrollController.position;
_scrollController.animateTo(
(pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent),
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
);
}
}
class _LogEntryCard extends StatefulWidget {
final LogEntry log;
final String Function(DateTime) formatTime;
final Color levelColor;
final IconData levelIcon;
const _LogEntryCard({required this.log, required this.formatTime, required this.levelColor, required this.levelIcon});
@override
State<_LogEntryCard> createState() => _LogEntryCardState();
}
class _LogEntryCardState extends State<_LogEntryCard> {
bool _isExpanded = false;
List<TextSpan> _buildLogSpans() {
final spans = <TextSpan>[];
for (var i = 0; i < _logs.length; i++) {
if (i > 0) spans.add(const TextSpan(text: '\n'));
final log = _logs[i];
final color = _getLevelColor(log.level);
spans.add(TextSpan(
text: '[${_formatTime(log.timestamp)}] ',
style: TextStyle(color: color.withValues(alpha: 0.6)),
));
spans.add(TextSpan(
text: '[${log.level.name.toUpperCase()}] ',
style: TextStyle(color: color, fontWeight: FontWeight.bold),
));
spans.add(TextSpan(text: log.message));
if (log.error != null) {
spans.add(TextSpan(
text: '\n Error: ${log.error}',
style: TextStyle(color: color),
));
}
if (log.stackTrace != null) {
spans.add(TextSpan(
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
));
}
}
return spans;
}
@override
Widget build(BuildContext context) {
final hasErrorOrStackTrace = widget.log.error != null || widget.log.stackTrace != null;
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: InkWell(
onTap: hasErrorOrStackTrace ? () => setState(() => _isExpanded = !_isExpanded) : null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIcon(widget.levelIcon, fill: 1, color: widget.levelColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
widget.log.level.name.toUpperCase(),
style: TextStyle(fontWeight: FontWeight.bold, color: widget.levelColor, fontSize: 12),
),
const SizedBox(width: 8),
Text(
widget.formatTime(widget.log.timestamp),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(widget.log.message, style: Theme.of(context).textTheme.bodyMedium),
],
return Focus(
canRequestFocus: false,
onKeyEvent: (node, event) {
final backResult = handleBackKeyNavigation(context, event);
if (backResult != KeyEventResult.ignored) return backResult;
if (event is KeyDownEvent || event is KeyRepeatEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_scroll(80);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_scroll(-80);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
CustomAppBar(
title: Text(t.screens.logs),
pinned: true,
actions: [
FocusableActionBar(
actions: [
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _loadLogs,
),
FocusableAction(
icon: Symbols.upload_rounded,
tooltip: t.logs.uploadLogs,
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
),
FocusableAction(
icon: Symbols.content_copy_rounded,
tooltip: t.logs.copyLogs,
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
),
FocusableAction(
icon: Symbols.delete_outline_rounded,
tooltip: t.logs.clearLogs,
onPressed: _logs.isNotEmpty ? _clearLogs : null,
),
],
),
],
),
if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: SelectableText.rich(
TextSpan(
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
height: 1.5,
),
children: _buildLogSpans(),
),
),
if (hasErrorOrStackTrace)
AppIcon(
_isExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded,
fill: 1,
color: Theme.of(context).iconTheme.color?.withValues(alpha: 0.6),
),
],
),
),
if (_isExpanded && hasErrorOrStackTrace) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
if (widget.log.error != null)
_buildDetailSection(title: t.logs.error, content: widget.log.error.toString()),
if (widget.log.stackTrace != null) ...[
const SizedBox(height: 12),
_buildDetailSection(title: t.logs.stackTrace, content: widget.log.stackTrace.toString()),
],
],
],
),
],
),
),
);
}
Widget _buildDetailSection({required String title, required String content}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: widget.levelColor, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[900] : Colors.grey[200],
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: SelectableText(
content,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
],
);
}
}
+16 -1
View File
@@ -22,7 +22,7 @@ import '../../providers/settings_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/user_profile_provider.dart';
import '../../services/keyboard_shortcuts_service.dart';
import '../../mpv/player/player_android.dart';
import '../../mpv/player/platform/player_android.dart';
import '../../services/settings_service.dart' as settings;
import '../../services/update_service.dart';
import '../../utils/snackbar_helper.dart';
@@ -71,6 +71,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
static const _kShowServerNameOnHubs = 'show_server_name_on_hubs';
static const _kAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
static const _kShowUnwatchedCount = 'show_unwatched_count';
static const _kHideSpoilers = 'hide_spoilers';
static const _kRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
static const _kConfirmExitOnBack = 'confirm_exit_on_back';
static const _kPlayerBackend = 'player_backend';
@@ -383,6 +384,20 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
);
},
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
focusNode: _focusTracker.get(_kHideSpoilers),
secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1),
title: Text(t.settings.hideSpoilers),
subtitle: Text(t.settings.hideSpoilersDescription),
value: settingsProvider.hideSpoilers,
onChanged: (value) async {
await settingsProvider.setHideSpoilers(value);
},
);
},
),
Consumer<UserProfileProvider>(
builder: (context, userProfileProvider, child) {
if (!userProfileProvider.hasMultipleUsers) return const SizedBox.shrink();
+128 -37
View File
@@ -11,8 +11,9 @@ import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../mpv/mpv.dart';
import '../mpv/player/player_android.dart';
import '../mpv/player/platform/player_android.dart';
import '../../services/bif_thumbnail_service.dart';
import '../../services/plex_client.dart';
import '../models/livetv_channel.dart';
import '../services/plex_api_cache.dart';
@@ -20,11 +21,12 @@ import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../utils/content_utils.dart';
import '../utils/plex_cache_parser.dart';
import '../models/plex_media_info.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../providers/playback_state_provider.dart';
import '../models/companion_remote/remote_command_type.dart';
import '../models/companion_remote/remote_command.dart';
import '../providers/companion_remote_provider.dart';
import '../services/companion_remote/companion_remote_receiver.dart';
import '../services/fullscreen_state_manager.dart';
@@ -138,8 +140,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
bool _isDisposingForNavigation = false;
bool _waitingForExternalSubsTrackSelection = false;
bool _isApplyingTrackSelection = false;
bool _isHandlingBack = false;
bool _hasThumbnails = false;
BifThumbnailService? _bifService;
// Live TV channel navigation
int _liveChannelIndex = -1;
@@ -170,6 +173,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Screen-level focus node: persists across loading/initialized phases so
// key events never escape the video player route.
late final FocusNode _screenFocusNode;
bool _reclaimingFocus = false;
// Cached setting: when false on Windows/Linux, ESC should not exit the player
bool _videoPlayerNavigationEnabled = false;
// App lifecycle state tracking
bool _wasPlayingBeforeInactive = false;
@@ -195,14 +202,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
return context.getClientForServer(widget.metadata.serverId!);
}
String? _buildThumbnailUrl(BuildContext context, Duration time) {
final partId = _currentMediaInfo?.partId;
if (partId == null || widget.isOffline) return null;
final client = _getClientForMetadata(context);
return '${client.config.baseUrl}/library/parts/$partId/indexes/sd/${time.inMilliseconds}'.withPlexToken(
client.config.token,
);
}
Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time);
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
@@ -393,6 +393,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
try {
// Load buffer size from settings
final settingsService = await SettingsService.getInstance();
_videoPlayerNavigationEnabled = settingsService.getVideoPlayerNavigationEnabled();
final bufferSizeMB = settingsService.getBufferSize();
final enableHardwareDecoding = settingsService.getEnableHardwareDecoding();
final debugLoggingEnabled = settingsService.getEnableDebugLogging();
@@ -422,6 +423,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
'#${bgOpacity.toRadixString(16).padLeft(2, '0').toUpperCase()}$bgColor',
);
await player!.setProperty('sub-ass-override', 'no');
await player!.setProperty('sub-ass-video-aspect-override', '1');
await player!.setProperty('sub-pos', settingsService.getSubtitlePosition().toString());
// Platform-specific settings
@@ -429,6 +431,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await player!.setProperty('audio-exclusive', 'yes');
}
// Audio passthrough (desktop only - sends bitstream to receiver)
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
if (settingsService.getAudioPassthrough()) {
await player!.setAudioPassthrough(true);
}
}
// HDR is controlled via custom hdr-enabled property on iOS/macOS/Windows
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows) {
final enableHDR = settingsService.getEnableHDR();
@@ -545,6 +554,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Listen to position for completion detection (fallback for unreliable MPV events)
_positionSubscription = player!.streams.position.listen((position) {
// Fallback for cases where playbackRestart doesn't fire (observed on some
// offline Android playback flows). Prevents a permanent loading spinner.
if (!_hasFirstFrame.value && position.inMilliseconds > 0) {
_hasFirstFrame.value = true;
}
final duration = player!.state.duration;
if (duration.inMilliseconds > 0 &&
position.inMilliseconds >= duration.inMilliseconds - 1000 &&
@@ -1015,17 +1030,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
setState(() {
_availableVersions = result.availableVersions.cast();
_currentMediaInfo = result.mediaInfo;
_hasThumbnails = false;
_bifService?.dispose();
_bifService = null;
});
// Check whether any thumbnails exist by requesting the first one
// Download and cache BIF thumbnail file
if (_currentMediaInfo?.partId != null && !widget.isOffline) {
final partId = _currentMediaInfo!.partId!;
final client = _getClientForMetadata(context);
client.checkThumbnailsAvailable(partId).then((available) {
// Guard against media having changed while the probe was in flight
final service = BifThumbnailService();
service.load(client, partId).then((_) {
// Guard against media having changed while the download was in flight
if (mounted && _currentMediaInfo?.partId == partId) {
setState(() => _hasThumbnails = available);
setState(() => _bifService = service);
} else {
service.dispose();
}
});
}
@@ -1083,10 +1102,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
} on PlaybackException catch (e) {
if (mounted) {
_hasFirstFrame.value = true; // Hide spinner on error
showErrorSnackBar(context, e.message);
}
} catch (e) {
if (mounted) {
_hasFirstFrame.value = true; // Hide spinner on error
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
@@ -1131,10 +1152,29 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
appLogger.d('Starting offline playback: $videoPath');
// Load cached media info so track selection (audio language) works offline
PlexMediaInfo? mediaInfo;
try {
final serverId = widget.metadata.serverId;
if (serverId != null) {
final cached = await PlexApiCache.instance.get(serverId, '/library/metadata/${widget.metadata.ratingKey}');
final metadataJson = PlexCacheParser.extractFirstMetadata(cached);
if (metadataJson != null) {
mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson);
}
appLogger.d(
'Offline media info: cached=${cached != null}, hasMedia=${metadataJson?['Media'] != null}, '
'audioTracks=${mediaInfo?.audioTracks.length ?? 0}, subtitleTracks=${mediaInfo?.subtitleTracks.length ?? 0}',
);
}
} catch (e) {
appLogger.d('Could not load cached media info for offline playback', error: e);
}
return PlaybackInitializationResult(
availableVersions: [],
videoUrl: videoPath.contains('://') ? videoPath : 'file://$videoPath',
mediaInfo: null,
mediaInfo: mediaInfo,
externalSubtitles: const [],
isOffline: true,
);
@@ -1593,6 +1633,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
_videoPIPManager?.onBeforeEnterPip = null;
_videoFilterManager?.dispose();
// Release cached BIF thumbnail data
_bifService?.dispose();
// Mark sleep timer for restart if truly exiting (not episode transition)
if (!_isReplacingWithVideo) {
SleepTimerService().markNeedsRestart();
@@ -1681,8 +1724,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
/// descendant has focus, so internal movement between child controls
/// does NOT trigger this.
void _onScreenFocusChanged() {
if (_reclaimingFocus) return;
if (!_screenFocusNode.hasFocus && mounted && !_isExiting.value) {
_reclaimingFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_reclaimingFocus = false;
if (mounted && !_isExiting.value && !_screenFocusNode.hasFocus) {
_screenFocusNode.requestFocus();
}
@@ -1694,6 +1740,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Toggle wakelock based on playback state
if (isPlaying) {
WakelockPlus.enable();
// Force a texture refresh on resume to unstick stale frames
// (Linux/macOS texture registrars can miss frame-available
// notifications after extended pause periods)
player?.updateFrame();
} else {
WakelockPlus.disable();
}
@@ -2041,26 +2091,63 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
}
/// Wait briefly for profile settings to load in offline mode.
/// This prevents default-track fallback when playback starts before
/// UserProfileProvider finishes initialization.
Future<void> _waitForProfileSettingsIfNeeded() async {
if (!widget.isOffline || !mounted) return;
final provider = context.read<UserProfileProvider>();
if (provider.profileSettings != null) return;
final completer = Completer<void>();
late VoidCallback listener;
listener = () {
if (provider.profileSettings != null && !completer.isCompleted) {
completer.complete();
}
};
provider.addListener(listener);
try {
await Future.any<void>([completer.future, Future.delayed(const Duration(seconds: 2))]);
} finally {
provider.removeListener(listener);
}
}
/// Apply track selection using the TrackSelectionService
Future<void> _applyTrackSelection() async {
if (!mounted || player == null) return;
if (!mounted || player == null || _isApplyingTrackSelection) return;
final profileSettings = context.read<UserProfileProvider>().profileSettings;
final settingsService = await SettingsService.getInstance();
final trackService = TrackSelectionService(
player: player!,
profileSettings: profileSettings,
metadata: widget.metadata,
plexMediaInfo: _currentMediaInfo,
);
_isApplyingTrackSelection = true;
try {
await _waitForProfileSettingsIfNeeded();
if (!mounted || player == null) return;
await trackService.selectAndApplyTracks(
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(),
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
);
final profileSettings = context.read<UserProfileProvider>().profileSettings;
final settingsService = await SettingsService.getInstance();
if (!mounted || player == null) return;
final trackService = TrackSelectionService(
player: player!,
profileSettings: profileSettings,
metadata: widget.metadata,
plexMediaInfo: _currentMediaInfo,
);
await trackService.selectAndApplyTracks(
preferredAudioTrack: widget.preferredAudioTrack,
preferredSubtitleTrack: widget.preferredSubtitleTrack,
defaultPlaybackSpeed: settingsService.getDefaultPlaybackSpeed(),
onAudioTrackChanged: _onAudioTrackChanged,
onSubtitleTrackChanged: _onSubtitleTrackChanged,
);
} catch (e) {
appLogger.w('Failed to apply track selection', error: e);
} finally {
_isApplyingTrackSelection = false;
}
}
/// Rating key used for series/movie level language preferences.
@@ -2327,7 +2414,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
canRequestFocus: isCurrentRoute,
onKeyEvent: (node, event) {
if (!isCurrentRoute) return KeyEventResult.ignored;
// Back keys always pass through — handled by PopScope (system back
// On Windows/Linux with navigation off, consume ESC so Flutter's
// DismissAction doesn't trigger a route pop. The video controls'
// global key handler manages fullscreen/controls toggle instead.
if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) {
return KeyEventResult.handled;
}
// Back keys pass through — handled by PopScope (system back
// gesture) or overlay sheet's onKeyEvent.
if (event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Self-heal: if this node itself has primary focus (no descendant
@@ -2484,9 +2577,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
shaderService: _shaderService,
// ignore: no-empty-block - setState triggers rebuild to reflect shader change
onShaderChanged: () => setState(() {}),
thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null
? (Duration time) => _buildThumbnailUrl(context, time)!
: null,
thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null,
isLive: widget.isLive,
liveChannelName: _liveChannelName,
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
+111
View File
@@ -0,0 +1,111 @@
import 'dart:isolate';
import 'dart:typed_data';
import 'plex_client.dart';
import '../utils/app_logger.dart';
/// A single BIF thumbnail entry: timestamp in milliseconds + JPEG bytes.
typedef BifEntry = ({int timestampMs, Uint8List imageBytes});
/// Parse raw BIF file bytes into a list of thumbnail entries.
///
/// BIF format:
/// - 0..7 : magic bytes (0x89 "BIF" 0x0D 0x0A 0x1A 0x0A)
/// - 8..11 : version (uint32 LE)
/// - 12..15 : image count (uint32 LE)
/// - 16..19 : timestamp multiplier (uint32 LE, ms per unit; 0 = 1000)
/// - 20..63 : reserved
/// - 64.. : index table — (imageCount + 1) entries of 8 bytes each:
/// [timestamp (uint32 LE), offset (uint32 LE)]
/// The last entry is a sentinel (timestamp = 0xFFFFFFFF).
///
/// Top-level function so it can be passed to [Isolate.run].
List<BifEntry> _parseBifBytes(Uint8List bytes) {
if (bytes.length < 64) return [];
final data = ByteData.sublistView(bytes);
// Validate magic: 0x89 B I F 0x0D 0x0A 0x1A 0x0A
const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
for (var i = 0; i < magic.length; i++) {
if (bytes[i] != magic[i]) return [];
}
final imageCount = data.getUint32(12, Endian.little);
var timestampMultiplier = data.getUint32(16, Endian.little);
if (timestampMultiplier == 0) timestampMultiplier = 1000;
// Index table starts at byte 64; each entry is 8 bytes.
// There are (imageCount + 1) entries (last is sentinel).
final indexTableSize = (imageCount + 1) * 8;
if (bytes.length < 64 + indexTableSize) return [];
final entries = <BifEntry>[];
for (var i = 0; i < imageCount; i++) {
final entryOffset = 64 + i * 8;
final timestamp = data.getUint32(entryOffset, Endian.little);
final imgOffset = data.getUint32(entryOffset + 4, Endian.little);
// Next entry's offset gives us the end of this image's data.
final nextEntryOffset = 64 + (i + 1) * 8;
final nextImgOffset = data.getUint32(nextEntryOffset + 4, Endian.little);
if (nextImgOffset <= imgOffset || nextImgOffset > bytes.length) continue;
entries.add((
timestampMs: timestamp * timestampMultiplier,
imageBytes: Uint8List.sublistView(bytes, imgOffset, nextImgOffset),
));
}
return entries;
}
/// Caches a full BIF file in memory and serves thumbnails by timestamp.
class BifThumbnailService {
List<BifEntry>? _entries;
/// Download and parse the BIF file for [partId].
/// Returns silently on failure (thumbnails simply won't be available).
Future<void> load(PlexClient client, int partId) async {
_entries = null;
try {
final bytes = await client.downloadBifFile(partId);
if (bytes == null || bytes.isEmpty) return;
_entries = await Isolate.run(() => _parseBifBytes(bytes));
} catch (e) {
appLogger.w('BIF download/parse failed', error: e);
}
}
/// Whether thumbnails have been loaded successfully.
bool get isAvailable => _entries != null && _entries!.isNotEmpty;
/// Return the JPEG bytes for the thumbnail nearest to [time].
/// Uses binary search for O(log n) lookup.
Uint8List? getThumbnail(Duration time) {
final entries = _entries;
if (entries == null || entries.isEmpty) return null;
final ms = time.inMilliseconds;
// Binary search for the largest timestamp <= ms.
var lo = 0;
var hi = entries.length - 1;
while (lo < hi) {
final mid = (lo + hi + 1) ~/ 2; // bias right
if (entries[mid].timestampMs <= ms) {
lo = mid;
} else {
hi = mid - 1;
}
}
return entries[lo].imageBytes;
}
/// Release cached data.
void dispose() {
_entries = null;
}
}
@@ -1,95 +0,0 @@
import 'dart:async';
import 'dart:convert';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
/// Service for managing recent Companion Remote sessions
class CompanionRemoteDiscoveryService {
static const String _storageKey = 'companion_remote_recent_sessions';
static const int _maxRecentSessions = 5;
final _recentSessions = <RecentRemoteSession>[];
final _recentSessionsController = StreamController<List<RecentRemoteSession>>.broadcast();
/// Stream of recent sessions
Stream<List<RecentRemoteSession>> get recentSessions => _recentSessionsController.stream;
/// Get current list of recent sessions
List<RecentRemoteSession> get currentSessions => List.unmodifiable(_recentSessions);
CompanionRemoteDiscoveryService() {
_loadRecentSessions();
}
/// Load recent sessions from storage
Future<void> _loadRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = storage.prefs.getString(_storageKey);
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_recentSessions.clear();
_recentSessions.addAll(list.map((e) => RecentRemoteSession.fromJson(e as Map<String, dynamic>)));
// Sort by last connected (most recent first)
_recentSessions.sort((a, b) => b.lastConnected.compareTo(a.lastConnected));
_recentSessionsController.add(currentSessions);
appLogger.d('Loaded ${_recentSessions.length} recent remote sessions');
}
} catch (e) {
appLogger.e('Failed to load recent sessions', error: e);
}
}
/// Save recent sessions to storage
Future<void> _saveRecentSessions() async {
try {
final storage = await StorageService.getInstance();
final json = jsonEncode(_recentSessions.map((e) => e.toJson()).toList());
await storage.prefs.setString(_storageKey, json);
appLogger.d('Saved ${_recentSessions.length} recent remote sessions');
} catch (e) {
appLogger.e('Failed to save recent sessions', error: e);
}
}
/// Add a session to recent list
Future<void> addRecentSession(RecentRemoteSession session) async {
// Remove existing entry for this session ID
_recentSessions.removeWhere((s) => s.sessionId == session.sessionId);
// Add new entry at the beginning
_recentSessions.insert(0, session);
// Limit to max sessions
if (_recentSessions.length > _maxRecentSessions) {
_recentSessions.removeRange(_maxRecentSessions, _recentSessions.length);
}
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Remove a session from recent list
Future<void> removeRecentSession(String sessionId) async {
_recentSessions.removeWhere((s) => s.sessionId == sessionId);
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Clear all recent sessions
Future<void> clearRecentSessions() async {
_recentSessions.clear();
await _saveRecentSessions();
_recentSessionsController.add(currentSessions);
}
/// Dispose resources
Future<void> dispose() async {
await _recentSessionsController.close();
}
}
@@ -6,7 +6,6 @@ import 'dart:math';
import 'package:web_socket_channel/io.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../utils/app_logger.dart';
@@ -2,7 +2,6 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_command_type.dart';
import '../../utils/app_logger.dart';
import '../../utils/key_event_simulator.dart';
+107 -2
View File
@@ -84,8 +84,13 @@ class DataAggregationService {
return [];
}
// For global hubs, fetch libraries to split "Recently Added" hubs by library
final libraries = useGlobalHubs
? (librariesByServer ?? groupLibrariesByServer(await getLibrariesFromAllServers()))
: librariesByServer;
return useGlobalHubs
? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys)
? _fetchGlobalHubs(clients, limit: limit, hiddenLibraryKeys: hiddenLibraryKeys, librariesByServer: libraries)
: _fetchLibraryHubs(
clients,
limit: limit,
@@ -99,6 +104,7 @@ class DataAggregationService {
Map<String, PlexClient> clients, {
int? limit,
Set<String>? hiddenLibraryKeys,
Map<String, List<PlexLibrary>>? librariesByServer,
}) async {
appLogger.d('Fetching global hubs from ${clients.length} servers');
@@ -150,7 +156,9 @@ class DataAggregationService {
});
final results = await Future.wait(hubFutures);
final result = _collectAndLimitResults(results, limit);
// Split "Recently Added" hubs that combine items from multiple libraries
final splitResults = results.map((hubs) => _splitRecentlyAddedHubs(hubs, librariesByServer)).toList();
final result = _collectAndLimitResults(splitResults, limit);
appLogger.i('Fetched ${result.length} global hubs from all servers');
@@ -300,6 +308,103 @@ class DataAggregationService {
return limit != null && limit < all.length ? all.sublist(0, limit) : all;
}
/// Split "Recently Added" hubs that contain items from multiple libraries
/// into separate per-library hubs, matching the official Plex client behavior.
List<PlexHub> _splitRecentlyAddedHubs(
List<PlexHub> hubs,
Map<String, List<PlexLibrary>>? librariesByServer,
) {
final result = <PlexHub>[];
for (final hub in hubs) {
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
if (!hubId.contains('.recent')) {
result.add(hub);
continue;
}
// Group items by librarySectionID
final groups = <int, List<PlexMetadata>>{};
final ungrouped = <PlexMetadata>[];
for (final item in hub.items) {
final sectionId = item.librarySectionID;
if (sectionId == null) {
ungrouped.add(item);
} else {
groups.putIfAbsent(sectionId, () => []).add(item);
}
}
// Single library (or no groupable items) — keep hub unchanged
if (groups.length <= 1) {
result.add(hub);
continue;
}
// Multiple libraries — create one hub per library
for (final entry in groups.entries) {
final items = entry.value;
final libraryName = _resolveLibraryName(items.first, librariesByServer);
final title = libraryName != null ? 'Recently Added in $libraryName' : hub.title;
result.add(PlexHub(
hubKey: hub.hubKey,
title: title,
type: hub.type,
hubIdentifier: '${hub.hubIdentifier}_${entry.key}',
size: items.length,
more: hub.more,
items: items,
serverId: hub.serverId,
serverName: hub.serverName,
));
}
// Keep ungrouped items in a hub with the original title
if (ungrouped.isNotEmpty) {
result.add(PlexHub(
hubKey: hub.hubKey,
title: hub.title,
type: hub.type,
hubIdentifier: hub.hubIdentifier,
size: ungrouped.length,
more: hub.more,
items: ungrouped,
serverId: hub.serverId,
serverName: hub.serverName,
));
}
}
return result;
}
/// Resolve library name from item metadata or library lookup map.
String? _resolveLibraryName(
PlexMetadata item,
Map<String, List<PlexLibrary>>? librariesByServer,
) {
// Try librarySectionTitle from the item itself (Plex API often includes it)
if (item.librarySectionTitle != null && item.librarySectionTitle!.isNotEmpty) {
return item.librarySectionTitle;
}
// Fall back to library lookup
if (librariesByServer != null && item.serverId != null && item.librarySectionID != null) {
final serverLibraries = librariesByServer[item.serverId];
if (serverLibraries != null) {
for (final lib in serverLibraries) {
if (lib.key == item.librarySectionID.toString()) {
return lib.title;
}
}
}
}
return null;
}
/// Base helper for per-server fan-out operations
///
/// Returns raw results as (serverId, result) tuples.
+82 -8
View File
@@ -309,9 +309,47 @@ class DownloadManagerService {
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
}
// One-time migration: normalize stored file paths that may contain a
// doubled base-dir prefix from an earlier bug in the recovery callback.
// Re-run on v2 to also fix paths without a leading / that the v1 migration missed.
final prefs = (await SettingsService.getInstance()).prefs;
if ((prefs.getInt('download_paths_normalized_version') ?? 0) < 2) {
final allItems = await _database.select(_database.downloadedMedia).get();
var fixed = 0;
for (final item in allItems) {
if (item.videoFilePath != null) {
final vfp = item.videoFilePath!;
var normalized = await _storageService.toRelativePath(vfp);
// If toRelativePath didn't help, try extracting from downloads/ onward
// for paths that lack a leading / but contain nested base-dir fragments
if (normalized == vfp) {
final idx = vfp.indexOf('downloads/');
if (idx > 0) normalized = vfp.substring(idx);
}
appLogger.d('Path migration: videoFilePath="$vfp", normalized="$normalized"');
if (normalized != vfp) {
await _database.updateVideoFilePath(item.globalKey, normalized);
fixed++;
}
}
if (item.thumbPath != null) {
final tp = item.thumbPath!;
var normalized = await _storageService.toRelativePath(tp);
if (normalized == tp) {
final idx = tp.indexOf('downloads/');
if (idx > 0) normalized = tp.substring(idx);
}
if (normalized != tp) {
await _database.updateArtworkPaths(globalKey: item.globalKey, thumbPath: normalized);
}
}
}
if (fixed > 0) appLogger.i('Normalized $fixed corrupted download path(s)');
await prefs.setInt('download_paths_normalized_version', 2);
}
// Scan drift for orphaned items stuck in 'downloading'
final allDownloads = await _database.select(_database.downloadedMedia).get();
for (final item in allDownloads) {
if (item.status == DownloadStatus.downloading.index) {
// Video already downloaded but post-processing didn't complete
@@ -451,9 +489,15 @@ class DownloadManagerService {
status: DownloadStatus.queued.index,
);
// Pin the already-cached API response for offline use
// (getMetadataWithImages was already called by download_provider, which cached with chapters/markers)
await _apiCache.pinForOffline(metadata.serverId!, metadata.ratingKey);
// Ensure metadata is in cache before pinning.
// Normally getMetadataWithImages already cached the full API response (with chapters/markers),
// but if the network failed during the provider's fetch, the cache entry may not exist.
final cached = await _apiCache.get(metadata.serverId!, '/library/metadata/${metadata.ratingKey}');
if (cached == null) {
await _cacheMetadataForOffline(metadata.serverId!, metadata.ratingKey, metadata);
} else {
await _apiCache.pinForOffline(metadata.serverId!, metadata.ratingKey);
}
// Add to queue
await _database.addToQueue(
@@ -501,11 +545,31 @@ class DownloadManagerService {
final serverId = parsed.serverId;
final ratingKey = parsed.ratingKey;
final metadata = await _apiCache.getMetadata(serverId, ratingKey);
if (metadata == null) throw Exception('Metadata not found in cache for $globalKey');
var metadata = await _apiCache.getMetadata(serverId, ratingKey);
if (metadata == null) {
// Cache miss — try re-fetching from server (cache may have been cleared between queue and prepare)
appLogger.w('Cache miss for $globalKey, attempting network re-fetch');
try {
final fetched = await client.getMetadataWithImages(ratingKey);
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
} catch (e) {
appLogger.w('Network re-fetch failed for $globalKey', error: e);
}
if (metadata == null) {
throw Exception('Metadata not found in cache and could not be fetched for $globalKey');
}
}
final playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
if (playbackData.videoUrl == null) throw Exception('Could not get video URL');
var playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
if (playbackData.videoUrl == null) {
// Cache may contain a synthetic entry (from _cacheMetadataForOffline) without
// Media/Part data. Force a fresh network fetch to populate the cache properly.
appLogger.w('No video URL from cache for $globalKey, retrying via network');
final fetched = await client.getMetadataWithImages(ratingKey);
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
if (playbackData.videoUrl == null) throw Exception('Could not get video URL for $globalKey');
}
final ext = _getExtensionFromUrl(playbackData.videoUrl!) ?? 'mp4';
@@ -890,6 +954,11 @@ class DownloadManagerService {
await _downloadSingleArtwork(serverId, metadata.art!, client);
}
// Download square background art
if (metadata.backgroundSquare != null) {
await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client);
}
// Store thumb reference in database (primary artwork for display)
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: metadata.thumb);
@@ -951,6 +1020,11 @@ class DownloadManagerService {
if (metadata.art != null) {
await _downloadSingleArtwork(serverId, metadata.art!, client);
}
// Download square background art
if (metadata.backgroundSquare != null) {
await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client);
}
}
/// Download chapter thumbnail images for a media item
+76 -21
View File
@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../models/plex_metadata.dart';
import '../utils/app_logger.dart';
import '../utils/formatters.dart';
import 'settings_service.dart';
import 'saf_storage_service.dart';
@@ -222,7 +223,11 @@ class DownloadStorageService {
String _sanitizeFileName(String name) {
// Remove invalid filesystem characters: < > : " / \ | ? *
// Also remove leading/trailing whitespace and dots
return name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '').replaceAll(RegExp(r'^\.+|\.+$'), '').trim();
return name
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '')
.replaceAll(RegExp(r'^\.+|\.+$'), '')
.replaceAll('.', '_')
.trim();
}
/// Ensure a directory exists, creating it if necessary
@@ -365,15 +370,17 @@ class DownloadStorageService {
Future<String> toRelativePath(String absolutePath) async {
final baseDir = await _getBaseAppDir();
// If the path starts with the base directory, strip it
if (absolutePath.startsWith(baseDir.path)) {
// Remove the base path and any leading separator
var relative = absolutePath.substring(baseDir.path.length);
if (relative.startsWith('/') || relative.startsWith('\\')) {
relative = relative.substring(1);
// Strip the base directory prefix iteratively — background_downloader
// recovery paths can contain the base dir doubled (e.g.
// /data/.../app_flutter/data/.../app_flutter/downloads/...).
var result = absolutePath;
while (result.startsWith(baseDir.path)) {
result = result.substring(baseDir.path.length);
if (result.startsWith('/') || result.startsWith('\\')) {
result = result.substring(1);
}
return relative;
}
if (result != absolutePath) return result;
// Already relative or from a different base - return as-is
return absolutePath;
@@ -392,25 +399,73 @@ class DownloadStorageService {
}
/// Convert a potentially absolute path (from old database entries) to absolute
/// This handles both old absolute paths and new relative paths
/// This handles both old absolute paths and new relative paths, including
/// corrupted paths that contain nested base-dir fragments without a leading slash
/// (e.g. "data/user/0/.../app_flutter/downloads/...").
Future<String> ensureAbsolutePath(String storedPath) async {
if (path.isAbsolute(storedPath)) {
// Already absolute - check if file exists at this path
if (await File(storedPath).exists()) {
return storedPath;
appLogger.d('ensureAbsolutePath: input="$storedPath", isAbsolute=${path.isAbsolute(storedPath)}');
final baseDir = await _getBaseAppDir();
final normalizedCandidates = <String>[];
void addCandidate(String candidate) {
if (candidate.isEmpty) return;
final normalized = path.normalize(candidate);
if (!normalizedCandidates.contains(normalized)) {
normalizedCandidates.add(normalized);
}
// File doesn't exist at absolute path - try to reconstruct
// Extract the relative portion (everything after 'downloads/')
final downloadsIndex = storedPath.indexOf('downloads/');
}
String trimLeadingSeparators(String value) => value.replaceFirst(RegExp(r'^[\\/]+'), '');
if (path.isAbsolute(storedPath)) {
// Keep the original absolute path first (covers valid custom download paths).
addCandidate(storedPath);
// Recover from doubled app base path corruption:
// /data/.../app_flutter/data/.../app_flutter/downloads/...
final firstBaseIndex = storedPath.indexOf(baseDir.path);
final secondBaseIndex = storedPath.indexOf(baseDir.path, firstBaseIndex + baseDir.path.length);
if (firstBaseIndex != -1 && secondBaseIndex != -1) {
final tail = trimLeadingSeparators(storedPath.substring(secondBaseIndex + baseDir.path.length));
addCandidate(path.join(baseDir.path, tail));
}
// Recover from paths that contain downloads/ but wrong prefix.
final downloadsIndex = storedPath.lastIndexOf('downloads/');
if (downloadsIndex != -1) {
final relativePart = storedPath.substring(downloadsIndex);
return await toAbsolutePath(relativePart);
addCandidate(await toAbsolutePath(relativePart));
}
} else {
// Normal relative path.
addCandidate(await toAbsolutePath(storedPath));
// Recover from nested base-dir fragment without leading slash.
final baseIndex = storedPath.indexOf(baseDir.path);
if (baseIndex > 0) {
final tail = trimLeadingSeparators(storedPath.substring(baseIndex + baseDir.path.length));
addCandidate(path.join(baseDir.path, tail));
}
// Recover from nested fragment containing downloads/.
final downloadsIndex = storedPath.lastIndexOf('downloads/');
if (downloadsIndex >= 0) {
addCandidate(await toAbsolutePath(storedPath.substring(downloadsIndex)));
}
// Can't reconstruct, return original
return storedPath;
}
// Relative path - convert to absolute
return await toAbsolutePath(storedPath);
// Prefer the first candidate that exists on disk.
for (final candidate in normalizedCandidates) {
if (await File(candidate).exists()) {
appLogger.d('ensureAbsolutePath: resolved="$candidate"');
return candidate;
}
}
// Fall back to the most conservative candidate if none currently exist.
final fallback = normalizedCandidates.isNotEmpty ? normalizedCandidates.first : await toAbsolutePath(storedPath);
appLogger.d('ensureAbsolutePath: resolved="$fallback" (fallback)');
return fallback;
}
/// Calculate total storage used by downloads
@@ -1,17 +0,0 @@
import 'fullscreen_state_manager.dart';
import 'macos_window_delegate.dart';
/// Custom window delegate that manages fullscreen state
/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled
/// directly in Swift's WindowDelegate. This class only updates Dart-side state.
class FullscreenWindowDelegate extends MacOSWindowDelegate {
@override
void windowWillEnterFullScreen() {
FullscreenStateManager().setFullscreen(true);
}
@override
void windowDidExitFullScreen() {
FullscreenStateManager().setFullscreen(false);
}
}
+29
View File
@@ -0,0 +1,29 @@
import 'dart:io';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/io_client.dart';
/// Custom cache manager for Plex image transcoding with connection limiting.
///
/// Limits concurrent HTTP connections to 6 per host (matching browser HTTP/1.1
/// behavior) to prevent overwhelming the Plex server's transcode pipeline when
/// many posters are visible simultaneously.
class PlexImageCacheManager extends CacheManager with ImageCacheManager {
static const _key = 'plexImageCache';
static final PlexImageCacheManager instance = PlexImageCacheManager._();
PlexImageCacheManager._()
: super(
Config(
_key,
stalePeriod: const Duration(days: 30),
maxNrOfCacheObjects: 5000,
fileService: HttpFileService(
httpClient: IOClient(
HttpClient()..maxConnectionsPerHost = 6,
),
),
),
);
}
-25
View File
@@ -1,25 +0,0 @@
import 'dart:io' show Platform;
import 'fullscreen_window_delegate.dart';
import 'macos_window_service.dart';
/// Service to manage macOS titlebar configuration
class MacOSTitlebarService {
static bool _initialized = false;
/// Initialize the custom titlebar setup.
///
/// Note: The initial window configuration (transparent titlebar, toolbar,
/// button positions, fullscreen presentation options) is now applied in
/// MainFlutterWindow.swift / WindowDelegate.swift BEFORE frame restoration
/// to prevent the window from shrinking on launch.
///
/// This method only sets up the Dart-side callbacks.
static Future<void> setupCustomTitlebar() async {
if (!Platform.isMacOS || _initialized) return;
_initialized = true;
await MacOSWindowService.initialize(enableWindowDelegate: true);
final delegate = FullscreenWindowDelegate();
MacOSWindowService.addWindowDelegate(delegate);
}
}
-20
View File
@@ -1,20 +0,0 @@
/// Abstract class for receiving macOS window delegate callbacks.
/// Extend this class and register with MacOSWindowService to receive
/// fullscreen transition events.
abstract class MacOSWindowDelegate {
/// Called when the window is about to enter fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowWillEnterFullScreen() {}
/// Called when the window has entered fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowDidEnterFullScreen() {}
/// Called when the window is about to exit fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowWillExitFullScreen() {}
/// Called when the window has exited fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowDidExitFullScreen() {}
}
+52 -1
View File
@@ -1,6 +1,27 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'macos_window_delegate.dart';
import 'fullscreen_state_manager.dart';
/// Abstract class for receiving macOS window delegate callbacks.
/// Extend this class and register with [MacOSWindowService] to receive
/// fullscreen transition events.
abstract class MacOSWindowDelegate {
/// Called when the window is about to enter fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowWillEnterFullScreen() {}
/// Called when the window has entered fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowDidEnterFullScreen() {}
/// Called when the window is about to exit fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowWillExitFullScreen() {}
/// Called when the window has exited fullscreen mode.
// ignore: no-empty-block - default no-op, subclasses override as needed
void windowDidExitFullScreen() {}
}
/// Service for manipulating macOS window properties.
/// This is a native implementation replacing the macos_window_utils package.
@@ -45,6 +66,21 @@ class MacOSWindowService {
// MARK: - Initialization
/// Initialize the window service and set up the titlebar.
///
/// Note: The initial window configuration (transparent titlebar, toolbar,
/// button positions, fullscreen presentation options) is now applied in
/// MainFlutterWindow.swift / WindowDelegate.swift BEFORE frame restoration
/// to prevent the window from shrinking on launch.
///
/// This method sets up the Dart-side callbacks for fullscreen state tracking.
static Future<void> setupCustomTitlebar() async {
if (!Platform.isMacOS || _initialized) return;
await initialize(enableWindowDelegate: true);
addWindowDelegate(_FullscreenWindowDelegate());
}
/// Initialize the window service.
/// Must be called before using other methods.
/// Set [enableWindowDelegate] to true to receive fullscreen callbacks.
@@ -94,3 +130,18 @@ class MacOSWindowService {
return await _channel.invokeMethod<bool>('isFullscreen') ?? false;
}
}
/// Internal window delegate that manages fullscreen state.
/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled
/// directly in Swift's WindowDelegate. This class only updates Dart-side state.
class _FullscreenWindowDelegate extends MacOSWindowDelegate {
@override
void windowWillEnterFullScreen() {
FullscreenStateManager().setFullscreen(true);
}
@override
void windowDidExitFullScreen() {
FullscreenStateManager().setFullscreen(false);
}
}
+9 -10
View File
@@ -83,7 +83,7 @@ class MultiServerManager {
final cachedEndpoint = storage.getServerEndpoint(serverId);
// Find best working connection, passing cached endpoint for fast-path
final streamIterator = StreamIterator(server.findBestWorkingConnection(preferredUri: cachedEndpoint));
final streamIterator = StreamIterator(server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: clientIdentifier));
if (!await streamIterator.moveNext()) {
throw Exception('No working connection found');
@@ -288,7 +288,9 @@ class MultiServerManager {
}
}
/// Test connection health for all servers
/// Test connection health for all servers.
/// Uses [PlexClient.isHealthy] which checks for HTTP 200, so servers with
/// invalid tokens (401) are correctly reported as offline.
Future<void> checkServerHealth() async {
appLogger.d('Checking health for ${_clients.length} servers');
@@ -296,13 +298,10 @@ class MultiServerManager {
final serverId = entry.key;
final client = entry.value;
try {
// Simple ping by fetching server identity
await client.getServerIdentity();
updateServerStatus(serverId, true);
} catch (e) {
appLogger.w('Server $serverId health check failed: $e');
updateServerStatus(serverId, false);
final healthy = await client.isHealthy();
updateServerStatus(serverId, healthy);
if (!healthy) {
appLogger.w('Server $serverId health check failed');
}
});
@@ -389,7 +388,7 @@ class MultiServerManager {
try {
appLogger.d('Starting connection optimization for ${server.name}', error: {'reason': reason});
await for (final connection in server.findBestWorkingConnection(preferredUri: cachedEndpoint)) {
await for (final connection in server.findBestWorkingConnection(preferredUri: cachedEndpoint, clientIdentifier: _clientIdentifier)) {
final newUrl = connection.uri;
// Check if this is actually a better connection than current
@@ -43,6 +43,9 @@ class PlaybackProgressTracker {
/// Timer ticks to skip before retrying after failures (exponential backoff).
int _ticksToSkip = 0;
/// Counts timer ticks while paused to send periodic "paused" heartbeats.
int _pausedTickCounter = 0;
PlaybackProgressTracker({
required this.client,
required this.metadata,
@@ -70,6 +73,7 @@ class PlaybackProgressTracker {
_progressTimer = Timer.periodic(updateInterval, (timer) {
if (player.state.playing) {
_pausedTickCounter = 0;
// Skip ticks when backing off after consecutive failures to avoid
// flooding the network with doomed requests during an outage.
if (_ticksToSkip > 0) {
@@ -77,6 +81,18 @@ class PlaybackProgressTracker {
return;
}
_sendProgress('playing');
} else {
// Send periodic "paused" updates to keep the Plex session alive
// (~60s with default 10s interval)
_pausedTickCounter++;
if (_pausedTickCounter >= 6) {
_pausedTickCounter = 0;
if (_ticksToSkip > 0) {
_ticksToSkip--;
return;
}
_sendProgress('paused');
}
}
});
+14 -13
View File
@@ -51,10 +51,9 @@ class PlexAuthService {
static Future<PlexAuthService> create() async {
final storage = await StorageService.getInstance();
final dio = Dio(BaseOptions(
connectTimeout: ConnectionTimeouts.plexTvConnect,
receiveTimeout: ConnectionTimeouts.plexTvReceive,
));
final dio = Dio(
BaseOptions(connectTimeout: ConnectionTimeouts.plexTvConnect, receiveTimeout: ConnectionTimeouts.plexTvReceive),
);
// Get or create client identifier
String? clientIdentifier = storage.getClientIdentifier();
@@ -276,7 +275,7 @@ class PlexServer {
factory PlexServer.fromJson(Map<String, dynamic> json) {
// Validate required fields first
if (!_isValidServerJson(json)) {
throw FormatException(
throw const FormatException(
'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)',
);
}
@@ -302,7 +301,7 @@ class PlexServer {
// If no valid connections were parsed, this server is unusable
if (connections.isEmpty) {
throw FormatException('Server has no valid connections');
throw const FormatException('Server has no valid connections');
}
DateTime? lastSeenAt;
@@ -392,7 +391,7 @@ class PlexServer {
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
/// Tests both plex.direct URI and direct IP for each connection
/// HTTPS connections are tested first, with HTTP as fallback
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri}) async* {
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* {
if (connections.isEmpty) {
appLogger.w('No connections available for server discovery');
return;
@@ -438,6 +437,7 @@ class PlexServer {
cachedCandidate.url,
accessToken,
timeout: preferredTimeout,
clientIdentifier: clientIdentifier,
);
if (result.success) {
@@ -457,7 +457,7 @@ class PlexServer {
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
for (final candidate in candidates) {
PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout).then((result) {
PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout, clientIdentifier: clientIdentifier).then((result) {
completedTests++;
if (!result.success) {
@@ -502,7 +502,7 @@ class PlexServer {
}
// Attempt HTTPS upgrade on the Phase 1 winner before emitting
final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate);
final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate, clientIdentifier: clientIdentifier);
final emitCandidate = upgradedFirstCandidate ?? firstCandidate;
final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url);
@@ -524,7 +524,7 @@ class PlexServer {
await Future.wait(
candidates.map((candidate) async {
final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2);
final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2, clientIdentifier: clientIdentifier);
if (result.success) {
candidateResults[candidate] = result;
@@ -548,7 +548,7 @@ class PlexServer {
// Emit the best connection if it's different from the first one
if (bestCandidate != null) {
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate) ?? bestCandidate;
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate;
final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
if (bestConnection.uri != firstConnection.uri) {
@@ -666,7 +666,7 @@ class PlexServer {
return urls;
}
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate) async {
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate, {String? clientIdentifier}) async {
final currentUrl = candidate.url;
if (currentUrl.startsWith('https://')) {
return null;
@@ -716,6 +716,7 @@ class PlexServer {
httpsUrl,
accessToken,
timeout: ConnectionTimeouts.connectionRace,
clientIdentifier: clientIdentifier,
);
if (!result.success) {
@@ -863,7 +864,7 @@ class PlexConnection {
factory PlexConnection.fromJson(Map<String, dynamic> json) {
// Validate required fields
if (!_isValidConnectionJson(json)) {
throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
throw const FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
}
return PlexConnection(
+62 -17
View File
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' show VoidCallback;
import 'package:dio/dio.dart';
@@ -32,6 +33,13 @@ import '../utils/plex_url_helper.dart';
import '../utils/watch_state_notifier.dart';
import 'plex_api_cache.dart';
/// Result of a paginated library content fetch
class LibraryContentResult {
final List<PlexMetadata> items;
final int totalSize;
const LibraryContentResult({required this.items, required this.totalSize});
}
/// Process hub JSON response in an isolate.
/// Top-level function so it can be passed to [Isolate.run].
List<PlexHub> _processHubResponse(String jsonStr, String serverId, String? serverName) {
@@ -208,6 +216,7 @@ class PlexClient {
String baseUrl,
String token, {
Duration timeout = const Duration(seconds: 5),
String? clientIdentifier,
}) async {
final stopwatch = Stopwatch()..start();
@@ -223,10 +232,17 @@ class PlexClient {
),
);
final response = await dio.get('/', options: Options(headers: {'X-Plex-Token': token}));
final headers = <String, String>{'X-Plex-Token': token};
if (clientIdentifier != null) {
headers['X-Plex-Client-Identifier'] = clientIdentifier;
headers['X-Plex-Product'] = 'Plezy';
headers['X-Plex-Device-Name'] = 'Plezy';
}
final response = await dio.get('/', options: Options(headers: headers));
stopwatch.stop();
final success = response.statusCode == 200 || response.statusCode == 401;
final success = response.statusCode == 200;
return ConnectionTestResult(
success: success,
@@ -259,11 +275,17 @@ class PlexClient {
String token, {
int attempts = 3,
Duration timeout = const Duration(seconds: 5),
String? clientIdentifier,
}) async {
final results = <ConnectionTestResult>[];
for (int i = 0; i < attempts; i++) {
final result = await testConnectionWithLatency(baseUrl, token, timeout: timeout);
final result = await testConnectionWithLatency(
baseUrl,
token,
timeout: timeout,
clientIdentifier: clientIdentifier,
);
// If any attempt fails, return failed result immediately
if (!result.success) {
@@ -364,6 +386,17 @@ class PlexClient {
return response.data;
}
/// Check if the server connection is healthy (reachable AND authenticated).
/// Returns true only if the server responds with HTTP 200.
Future<bool> isHealthy() async {
try {
final response = await _dio.get('/identity');
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Get library sections
/// Returns libraries automatically tagged with this client's serverId and serverName
Future<List<PlexLibrary>> getLibraries() async {
@@ -372,7 +405,7 @@ class PlexClient {
}
/// Get library content by section ID
Future<List<PlexMetadata>> getLibraryContent(
Future<LibraryContentResult> getLibraryContent(
String sectionId, {
int? start,
int? size,
@@ -394,7 +427,11 @@ class PlexClient {
cancelToken: cancelToken,
);
return _extractMetadataList(response);
final items = _extractMetadataList(response);
final container = _getMediaContainer(response);
final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
return LibraryContentResult(items: items, totalSize: totalSize);
}
/// Parse list of PlexMetadata from a cached response
@@ -896,17 +933,20 @@ class PlexClient {
return '${config.baseUrl}/$path'.withPlexToken(config.token);
}
/// Check whether thumbnail previews are available for a given part.
/// Returns true if the server responds with 200 to the first thumbnail.
Future<bool> checkThumbnailsAvailable(int partId) async {
/// Download the full BIF (Base Index Frames) file for a given part.
/// Returns the raw bytes, or null on failure.
Future<Uint8List?> downloadBifFile(int partId) async {
try {
final response = await _dio.get(
'/library/parts/$partId/indexes/sd/0',
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 5)),
final response = await _dio.get<List<int>>(
'/library/parts/$partId/indexes/sd',
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 30)),
);
return response.statusCode == 200;
if (response.statusCode == 200 && response.data != null) {
return Uint8List.fromList(response.data!);
}
return null;
} catch (_) {
return false;
return null;
}
}
@@ -1555,19 +1595,24 @@ class PlexClient {
/// Set artwork from a URL (can be a Plex internal path or external URL)
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
return _wrapBoolApiCall(
() => _dio.post('/library/metadata/$ratingKey/$element', queryParameters: {'url': url}),
() => _dio.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
'Failed to set artwork from URL',
);
}
/// Upload artwork from binary data
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
return _wrapBoolApiCall(
() => _dio.post(
'/library/metadata/$ratingKey/$element',
() => _dio.put(
'/library/metadata/$ratingKey/$setElement',
data: bytes,
options: Options(headers: {'Content-Length': bytes.length}),
options: Options(
headers: {'Content-Length': bytes.length},
contentType: 'application/octet-stream',
),
),
'Failed to upload artwork',
);
+20 -6
View File
@@ -1,9 +1,13 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../utils/app_logger.dart';
import 'plex_auth_service.dart';
import 'storage_service.dart';
enum ServerRefreshResult { success, networkError, authError, noToken }
/// Centralized server configuration registry
/// Manages which servers are available and their configurations
class ServerRegistry {
@@ -95,13 +99,15 @@ class ServerRegistry {
appLogger.i('Cleared all servers from registry');
}
/// Refresh servers from Plex API and update storage
/// This updates connection info (IPs, ports) that may have changed
Future<void> refreshServersFromApi() async {
/// Refresh servers from Plex API and update storage.
/// This updates connection info (IPs, ports) that may have changed.
/// Returns [ServerRefreshResult.authError] when the stored token is rejected
/// (e.g. after removing a Plex profile PIN), so the caller can redirect to re-auth.
Future<ServerRefreshResult> refreshServersFromApi() async {
final token = _storage.getPlexToken();
if (token == null || token.isEmpty) {
appLogger.d('No Plex token available, skipping server refresh');
return;
return ServerRefreshResult.noToken;
}
try {
@@ -111,7 +117,7 @@ class ServerRegistry {
if (freshServers.isEmpty) {
appLogger.w('API returned no servers, keeping existing data');
return;
return ServerRefreshResult.success;
}
// Get existing servers to preserve any local-only data
@@ -133,9 +139,17 @@ class ServerRegistry {
await saveServers(updatedServers);
appLogger.i('Refreshed ${updatedServers.length} servers from API');
return ServerRefreshResult.success;
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
appLogger.w('Plex token is invalid (401), re-authentication required');
return ServerRefreshResult.authError;
}
appLogger.w('Failed to refresh servers from API, using cached data', error: e);
return ServerRefreshResult.networkError;
} catch (e, stackTrace) {
appLogger.w('Failed to refresh servers from API, using cached data', error: e, stackTrace: stackTrace);
// Don't rethrow - we can continue with cached servers
return ServerRefreshResult.networkError;
}
}
}
+43 -21
View File
@@ -70,6 +70,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyUseExoPlayer = 'use_exoplayer';
static const String _keyAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
static const String _keyShowUnwatchedCount = 'show_unwatched_count';
static const String _keyHideSpoilers = 'hide_spoilers';
static const String _keyGlobalShaderPreset = 'global_shader_preset';
static const String _keyRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
static const String _keyUseExternalPlayer = 'use_external_player';
@@ -77,6 +78,7 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyCustomExternalPlayers = 'custom_external_players';
static const String _keyConfirmExitOnBack = 'confirm_exit_on_back';
static const String _keyAmbientLighting = 'ambient_lighting';
static const String _keyAudioPassthrough = 'audio_passthrough';
SettingsService._();
@@ -397,27 +399,27 @@ class SettingsService extends BaseSharedPreferencesService {
// HotKey Objects (New implementation)
Map<String, HotKey> getDefaultKeyboardHotkeys() {
return {
'play_pause': HotKey(key: PhysicalKeyboardKey.space),
'volume_up': HotKey(key: PhysicalKeyboardKey.arrowUp),
'volume_down': HotKey(key: PhysicalKeyboardKey.arrowDown),
'seek_forward': HotKey(key: PhysicalKeyboardKey.arrowRight),
'seek_backward': HotKey(key: PhysicalKeyboardKey.arrowLeft),
'seek_forward_large': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]),
'seek_backward_large': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]),
'fullscreen_toggle': HotKey(key: PhysicalKeyboardKey.keyF),
'mute_toggle': HotKey(key: PhysicalKeyboardKey.keyM),
'subtitle_toggle': HotKey(key: PhysicalKeyboardKey.keyS),
'audio_track_next': HotKey(key: PhysicalKeyboardKey.keyA),
'subtitle_track_next': HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]),
'chapter_next': HotKey(key: PhysicalKeyboardKey.keyN),
'chapter_previous': HotKey(key: PhysicalKeyboardKey.keyP),
'speed_increase': HotKey(key: PhysicalKeyboardKey.equal),
'speed_decrease': HotKey(key: PhysicalKeyboardKey.minus),
'speed_reset': HotKey(key: PhysicalKeyboardKey.keyR),
'sub_seek_next': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]),
'sub_seek_prev': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]),
'shader_toggle': HotKey(key: PhysicalKeyboardKey.keyG),
'skip_marker': HotKey(key: PhysicalKeyboardKey.enter),
'play_pause': const HotKey(key: PhysicalKeyboardKey.space),
'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp),
'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown),
'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight),
'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft),
'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]),
'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]),
'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF),
'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM),
'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS),
'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA),
'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]),
'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN),
'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP),
'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal),
'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus),
'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR),
'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]),
'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]),
'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG),
'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter),
};
}
@@ -1036,6 +1038,15 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyShowUnwatchedCount) ?? true; // Default: enabled (show counts)
}
// Hide Spoilers (blur thumbnails and hide descriptions for unwatched episodes)
Future<void> setHideSpoilers(bool enabled) async {
await prefs.setBool(_keyHideSpoilers, enabled);
}
bool getHideSpoilers() {
return prefs.getBool(_keyHideSpoilers) ?? false; // Default: disabled
}
// Global Shader Preset (for MPV video enhancement)
Future<void> setGlobalShaderPreset(String presetId) async {
await prefs.setString(_keyGlobalShaderPreset, presetId);
@@ -1129,6 +1140,15 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyAmbientLighting) ?? false;
}
// Audio Passthrough
Future<void> setAudioPassthrough(bool enabled) async {
await prefs.setBool(_keyAudioPassthrough, enabled);
}
bool getAudioPassthrough() {
return prefs.getBool(_keyAudioPassthrough) ?? false;
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -1177,6 +1197,7 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyUseExoPlayer),
prefs.remove(_keyAlwaysKeepSidebarOpen),
prefs.remove(_keyShowUnwatchedCount),
prefs.remove(_keyHideSpoilers),
prefs.remove(_keyGlobalShaderPreset),
prefs.remove(_keyRequireProfileSelectionOnOpen),
prefs.remove(_keyUseExternalPlayer),
@@ -1184,6 +1205,7 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyCustomExternalPlayers),
prefs.remove(_keyConfirmExitOnBack),
prefs.remove(_keyAmbientLighting),
prefs.remove(_keyAudioPassthrough),
prefs.remove(_keyBufferSizeMigratedToAuto),
]);
}
+1 -1
View File
@@ -95,7 +95,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
color: c.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: const BorderRadius.all(Radius.circular(14))),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14))),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
+1 -2
View File
@@ -17,9 +17,8 @@ class CodecUtils {
case 'srt':
return 'srt';
case 'ass':
return 'ass';
case 'ssa':
return 'ssa';
return 'ass';
case 'webvtt':
case 'vtt':
return 'vtt';
+9
View File
@@ -100,4 +100,13 @@ extension PlexMetadataType on PlexMetadata {
bool get isClip => _lowerType == ContentTypes.clip;
bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType);
bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType);
/// Whether this episode should have spoiler protection applied.
/// True when the item is an unwatched episode with no active progress.
bool get shouldHideSpoiler {
if (!isEpisode) return false;
if (isWatched) return false;
if (viewOffset != null && viewOffset! > 0) return false;
return true;
}
}
+2 -2
View File
@@ -115,8 +115,8 @@ Future<({bool confirmed, bool checked})> showConfirmDialogWithCheckbox(
/// Shows a delete confirmation dialog.
/// Convenience wrapper around [showConfirmDialog] with destructive styling.
Future<bool> showDeleteConfirmation(BuildContext context, {required String title, required String message}) {
return showConfirmDialog(context, title: title, message: message, confirmText: t.common.delete, isDestructive: true);
Future<bool> showDeleteConfirmation(BuildContext context, {required String title, required String message, String? confirmText}) {
return showConfirmDialog(context, title: title, message: message, confirmText: confirmText ?? t.common.delete, isDestructive: true);
}
/// Shows a text input dialog for creating/naming items
-37
View File
@@ -185,43 +185,6 @@ String formatFinishTime(Duration remaining, {double rate = 1.0}) {
return formatter.format(finishTime);
}
/// Formats a DateTime as a relative time string (e.g., "just now", "5m", "3h", "2d", or a full date).
/// Uses the `duration` package for localized unit names.
///
/// Used for: recent connections timestamps.
String formatRelativeTime(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inMinutes < 1) {
return prettyDuration(
Duration.zero,
abbreviated: true,
locale: _getDurationLocale(),
tersity: DurationTersity.minute,
upperTersity: DurationTersity.minute,
);
} else if (difference.inDays < 7) {
return prettyDuration(
difference,
abbreviated: true,
locale: _getDurationLocale(),
delimiter: ' ',
spacer: '',
tersity: DurationTersity.minute,
upperTersity: () {
if (difference.inDays >= 1) return DurationTersity.day;
if (difference.inHours >= 1) return DurationTersity.hour;
return DurationTersity.minute;
}(),
maxUnits: 1,
);
} else {
final formatter = DateFormat.yMd(LocaleSettings.currentLocale.languageCode);
return formatter.format(date);
}
}
/// Takes a list of strings and returns one long string with each item in the list concatenated by a bullet
String toBulletedString(List<String> parts) {
return parts.join(' · ');
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
Route<T> fadeRoute<T>(Widget page) {
return PageRouteBuilder<T>(
opaque: false,
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 500),
);
}
@@ -54,6 +54,14 @@ class WatchTogetherSyncManager {
// Whether the first coordinated play has completed (after this, late joiners catch up via positionSync)
bool _firstPlayCompleted = false;
// Clock offset estimation (NTP-style)
// Offset = how far ahead the host's clock is vs ours (in ms)
int _clockOffset = 0;
bool _hasClockOffset = false;
int? _pendingPingTimestamp;
Timer? _clockSyncTimer;
static const Duration _clockSyncInterval = Duration(seconds: 5);
// Track last known state to avoid duplicate broadcasts
bool _lastKnownPlaying = false;
double _lastKnownRate = 1.0;
@@ -116,6 +124,7 @@ class WatchTogetherSyncManager {
// popping out of the previous player).
if (!_session.isHost) {
_peerService.broadcast(SyncMessage.requestSessionConfig(peerId: _peerService.myPeerId));
_startClockSync();
}
appLogger.d('WatchTogether: Player attached, isHost: ${_session.isHost}');
@@ -150,6 +159,11 @@ class WatchTogetherSyncManager {
_hasAnnouncedReady = false;
_deferredPlay = false;
_deferredPlayPosition = null;
_clockSyncTimer?.cancel();
_clockSyncTimer = null;
_clockOffset = 0;
_hasClockOffset = false;
_pendingPingTimestamp = null;
_playingSubscription?.cancel();
_bufferingSubscription?.cancel();
@@ -249,6 +263,69 @@ class WatchTogetherSyncManager {
});
}
/// Start NTP-style clock offset measurement (guest only)
void _startClockSync() {
_clockSyncTimer?.cancel();
_hasClockOffset = false;
_clockOffset = 0;
_pendingPingTimestamp = null;
// Initial burst of 3 pings for fast convergence
int burstCount = 0;
Timer.periodic(const Duration(milliseconds: 200), (timer) {
if (burstCount >= 3 || _player == null) {
timer.cancel();
return;
}
_sendClockPing();
burstCount++;
});
// Then continue at regular interval
_clockSyncTimer = Timer.periodic(_clockSyncInterval, (_) {
if (_player != null) _sendClockPing();
});
}
/// Send a clock-sync ping (guest only)
void _sendClockPing() {
final now = DateTime.now().millisecondsSinceEpoch;
_pendingPingTimestamp = now;
_peerService.broadcast(SyncMessage.ping(now, peerId: _peerService.myPeerId));
}
/// Process a clock-sync pong and update clock offset (guest only)
void _processClockPong(SyncMessage message) {
if (_pendingPingTimestamp == null || message.pingId != _pendingPingTimestamp) {
return; // Not our ping, or stale
}
_pendingPingTimestamp = null;
final t1 = message.pingId!; // Our original send timestamp
final t2 = message.timestamp; // Host's timestamp when it created the pong
final t3 = DateTime.now().millisecondsSinceEpoch;
final rtt = t3 - t1;
if (rtt < 0 || rtt > 10000) {
appLogger.w('WatchTogether: Discarding clock sample with RTT=${rtt}ms');
return;
}
// clockOffset = how far ahead host's clock is relative to ours
final sampleOffset = t2 - t1 - (rtt ~/ 2);
if (!_hasClockOffset) {
_clockOffset = sampleOffset;
_hasClockOffset = true;
appLogger.d('WatchTogether: Initial clock offset: ${_clockOffset}ms (RTT: ${rtt}ms)');
} else {
// Exponential moving average
const alpha = 0.3;
_clockOffset = (_clockOffset + (alpha * (sampleOffset - _clockOffset)).round());
appLogger.d('WatchTogether: Clock offset updated: ${_clockOffset}ms (sample: ${sampleOffset}ms, RTT: ${rtt}ms)');
}
}
/// Check if this peer can control playback
bool _canControl() {
if (_session.controlMode == ControlMode.anyone) {
@@ -407,12 +484,19 @@ class WatchTogetherSyncManager {
case SyncMessageType.ping:
if (message.pingId != null) {
_peerService.broadcast(SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId));
final pong = SyncMessage.pong(message.pingId!, peerId: _peerService.myPeerId);
if (message.peerId != null) {
_peerService.sendTo(message.peerId!, pong);
} else {
_peerService.broadcast(pong);
}
}
break;
case SyncMessageType.pong:
// Could be used for latency measurement
if (message.pingId != null && !_session.isHost) {
_processClockPong(message);
}
break;
case SyncMessageType.mediaSwitch:
@@ -522,7 +606,15 @@ class WatchTogetherSyncManager {
if (_player == null || _session.isHost) return;
final localPosition = _player!.state.position;
final networkDelay = DateTime.now().millisecondsSinceEpoch - remoteTimestamp;
final now = DateTime.now().millisecondsSinceEpoch;
// Translate host's timestamp to our local time frame using clock offset
// _clockOffset = hostClock - localClock, so localEquivalent = remoteTimestamp - _clockOffset
final adjustedRemoteTimestamp = remoteTimestamp - _clockOffset;
final rawDelay = now - adjustedRemoteTimestamp;
// Before clock offset is available, use 0 (compare positions directly)
final networkDelay = _hasClockOffset ? rawDelay.clamp(0, 5000) : 0;
// Estimate where remote should be now, accounting for playback time elapsed
Duration estimatedRemoteNow = remotePosition;
@@ -682,6 +774,7 @@ class WatchTogetherSyncManager {
/// Dispose resources
void dispose() {
_clockSyncTimer?.cancel();
detachPlayer();
_peerReady.clear();
_hasAnnouncedReady = false;
+30 -27
View File
@@ -187,37 +187,40 @@ class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
return FocusableWrapper(
borderRadius: 8,
onSelect: () => _selectArtwork(artwork),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: PlexOptimizedImage(
client: widget.client,
imagePath: thumbUrl,
fit: BoxFit.contain,
child: GestureDetector(
onTap: () => _selectArtwork(artwork),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
),
),
if (isSelected)
Positioned(
right: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: PlexOptimizedImage(
client: widget.client,
imagePath: thumbUrl,
fit: BoxFit.contain,
),
child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary),
),
),
],
if (isSelected)
Positioned(
right: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary),
),
),
],
),
),
);
},
+10
View File
@@ -48,6 +48,12 @@ class FocusableListTile extends StatefulWidget {
/// An optional color to display behind the menu item when being hovered.
final Color? hoverColor;
/// An optional color for the text of the list tile.
final Color? textColor;
/// An optional color for the icon of the list tile.
final Color? iconColor;
const FocusableListTile({
super.key,
this.title,
@@ -64,6 +70,8 @@ class FocusableListTile extends StatefulWidget {
this.contentPadding,
this.suppressInitialSelect = false,
this.hoverColor,
this.textColor,
this.iconColor,
});
@override
@@ -89,6 +97,8 @@ class _FocusableListTileState extends State<FocusableListTile> {
focusNode: widget.suppressInitialSelect ? null : widget.focusNode,
autofocus: widget.suppressInitialSelect ? false : widget.autofocus,
hoverColor: widget.hoverColor,
textColor: widget.textColor,
iconColor: widget.iconColor,
);
if (!widget.suppressInitialSelect) {
+29 -13
View File
@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:plezy/utils/content_utils.dart';
import 'package:plezy/widgets/app_icon.dart';
@@ -557,8 +559,8 @@ class _MediaCardList extends StatelessWidget {
),
const SizedBox(height: 4),
],
// Summary
if (item.summary != null) ...[
// Summary (hidden when spoiler protection is active)
if (!(item is PlexMetadata && context.watch<SettingsProvider>().hideSpoilers && (item as PlexMetadata).shouldHideSpoiler) && item.summary != null) ...[
Text(
item.summary!,
maxLines: _summaryMaxLines,
@@ -605,12 +607,25 @@ Widget _buildPosterImage(
localFilePath: localPosterPath,
);
} else if (item is PlexMetadata) {
final episodePosterMode = context.watch<SettingsProvider>().episodePosterMode;
final settingsProvider = context.watch<SettingsProvider>();
final episodePosterMode = settingsProvider.episodePosterMode;
final shouldBlur = settingsProvider.hideSpoilers && item.shouldHideSpoiler;
posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext);
Widget image;
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) {
return PlexOptimizedImage.thumb(
image = PlexOptimizedImage.thumb(
client: isOffline ? null : context.getClientWithFallback(item.serverId),
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
localFilePath: localPosterPath,
);
} else {
image = PlexOptimizedImage.poster(
client: isOffline ? null : context.getClientWithFallback(item.serverId),
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
@@ -620,14 +635,15 @@ Widget _buildPosterImage(
);
}
return PlexOptimizedImage.poster(
client: isOffline ? null : context.getClientWithFallback(item.serverId),
imagePath: posterUrl,
width: knownWidth ?? double.infinity,
height: knownHeight ?? double.infinity,
fit: BoxFit.cover,
localFilePath: localPosterPath,
);
if (shouldBlur) {
return ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: image,
),
);
}
return image;
}
return SkeletonLoader(
@@ -828,7 +844,7 @@ class _SkeletonLoaderState extends State<SkeletonLoader> with SingleTickerProvid
identifier: "skeleton-loader",
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: _animation.value),
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: _animation.value * 0.15),
borderRadius: widget.borderRadius ?? BorderRadius.circular(tokens(context).radiusSm),
),
child: widget.child,
+12 -5
View File
@@ -37,8 +37,9 @@ class _MenuAction {
final IconData icon;
final String label;
final Color? hoverColor;
final Color? foregroundColor;
_MenuAction({required this.value, required this.icon, required this.label, this.hoverColor});
_MenuAction({required this.value, required this.icon, required this.label, this.hoverColor, this.foregroundColor});
}
/// A reusable wrapper widget that adds a context menu (long press / right click)
@@ -284,9 +285,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
menuActions.add(
_MenuAction(
value: 'delete_media',
icon: Symbols.delete_rounded,
label: t.common.delete,
icon: Symbols.delete_forever_rounded,
label: t.mediaMenu.deleteFromServer,
hoverColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.error,
),
);
}
@@ -1137,8 +1139,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
// Show confirmation dialog
final confirmed = await showDeleteConfirmation(
context,
title: t.common.delete,
message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n${t.mediaMenu.deleteMultipleWarning}" : ""}",
title: t.mediaMenu.deleteFromServer,
message: "${t.mediaMenu.confirmDelete}${isMultipleMediaItems ? "\n\n${t.mediaMenu.deleteMultipleWarning}" : ""}",
confirmText: t.mediaMenu.deleteFromServer,
);
if (!confirmed || !context.mounted) return;
@@ -1330,6 +1333,8 @@ class _FocusableContextMenuSheetState extends State<_FocusableContextMenuSheet>
title: Text(action.label),
onTap: () => OverlaySheetController.closeAdaptive(context, action.value),
hoverColor: action.hoverColor,
textColor: action.foregroundColor,
iconColor: action.foregroundColor,
);
}),
],
@@ -1435,6 +1440,8 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> {
title: Text(action.label),
onTap: () => Navigator.pop(context, action.value),
hoverColor: action.hoverColor,
textColor: action.foregroundColor,
iconColor: action.foregroundColor,
);
}).toList(),
),
+39 -11
View File
@@ -48,14 +48,18 @@ class OverlaySheetController {
/// Whether a sheet is currently showing (including while animating closed).
bool get isOpen => _state._isOpen;
/// Show a bottom sheet with [builder] content. Returns a Future that completes
/// Show a sheet with [builder] content. Returns a Future that completes
/// when the sheet is closed (with an optional result).
///
/// [alignment] controls where the sheet appears. Defaults to
/// [Alignment.bottomCenter]. Use [Alignment.topCenter] to anchor at the top.
Future<T?> show<T>({
required WidgetBuilder builder,
BoxConstraints? constraints,
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
return _state._show<T>(
builder: builder,
@@ -63,6 +67,7 @@ class OverlaySheetController {
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
);
}
@@ -98,6 +103,7 @@ class OverlaySheetController {
bool barrierDismissible = true,
bool isScrollControlled = false,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
final controller = maybeOf(context);
if (controller != null) {
@@ -107,6 +113,7 @@ class OverlaySheetController {
backgroundColor: backgroundColor,
barrierDismissible: barrierDismissible,
initialFocusNode: initialFocusNode,
alignment: alignment,
);
}
return showModalBottomSheet<T>(
@@ -176,7 +183,7 @@ class OverlaySheetHost extends StatefulWidget {
class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final Animation<Offset> _slideAnimation;
late final CurvedAnimation _slideCurve;
late final Animation<double> _barrierAnimation;
late final OverlaySheetController _controller;
@@ -188,6 +195,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
bool _barrierDismissible = true;
BoxConstraints? _constraints;
Color? _explicitBackgroundColor;
Alignment _alignment = Alignment.bottomCenter;
// Drag-to-dismiss state
double _dragOffset = 0;
@@ -200,8 +208,10 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this);
_slideAnimation = Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic),
_slideCurve = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_barrierAnimation = Tween<double>(
@@ -218,6 +228,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
}
}
_sheetFocusScopeNode.dispose();
_slideCurve.dispose();
_animationController.dispose();
super.dispose();
}
@@ -228,6 +239,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Color? backgroundColor,
bool barrierDismissible = true,
FocusNode? initialFocusNode,
Alignment alignment = Alignment.bottomCenter,
}) {
// If already open, close first (instant)
if (_isOpen) {
@@ -250,6 +262,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
_barrierDismissible = barrierDismissible;
_constraints = constraints;
_explicitBackgroundColor = backgroundColor;
_alignment = alignment;
_dragOffset = 0;
_isDragging = false;
});
@@ -448,29 +461,44 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
Widget _buildSheet(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 600;
final isTop = _alignment.y < 0;
final effectiveConstraints =
_constraints ??
BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75);
// Slide direction depends on alignment: bottom sheets slide up, top sheets slide down.
final slideBegin = isTop ? const Offset(0, -1) : const Offset(0, 1);
final borderRadius = isTop
? const BorderRadius.vertical(bottom: Radius.circular(16))
: const BorderRadius.vertical(top: Radius.circular(16));
Widget sheet = FocusScope(
node: _sheetFocusScopeNode,
child: Focus(
canRequestFocus: false,
skipTraversal: true,
onKeyEvent: _handleKeyEvent,
child: SlideTransition(
position: _slideAnimation,
child: AnimatedBuilder(
animation: _slideCurve,
builder: (context, child) {
final slideOffset = Offset.lerp(slideBegin, Offset.zero, _slideCurve.value)!;
return FractionalTranslation(
translation: slideOffset,
child: child,
);
},
child: Align(
alignment: Alignment.bottomCenter,
alignment: _alignment,
child: Transform.translate(
offset: Offset(0, _dragOffset.clamp(0, double.infinity)),
child: Material(
color: _explicitBackgroundColor ?? Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
top: !isTop,
bottom: isTop,
child: ConstrainedBox(
constraints: effectiveConstraints,
child: _pageStack.isNotEmpty ? _pageStack.last.builder(context) : const SizedBox.shrink(),
@@ -483,8 +511,8 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
),
);
// Swipe-down-to-dismiss (skip on TV where there's no touchscreen)
if (!PlatformDetector.isTV()) {
// Swipe-down-to-dismiss (skip on TV and for top-aligned sheets)
if (!PlatformDetector.isTV() && !isTop) {
sheet = GestureDetector(
onVerticalDragStart: (_) {
_isDragging = true;
+2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/image_cache_service.dart';
import '../../services/plex_client.dart';
import '../utils/plex_image_helper.dart';
import 'media_card.dart';
@@ -338,6 +339,7 @@ class PlexOptimizedImage extends StatelessWidget {
image: CachedNetworkImageProvider(
imageUrl,
cacheKey: effectiveCacheKey,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
maxHeight: memHeight,
),
@@ -68,6 +68,7 @@ class DesktopVideoControls extends StatefulWidget {
final VoidCallback? onLoadSeekTimes;
final VoidCallback? onCancelAutoHide;
final VoidCallback? onStartAutoHide;
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
final String serverId;
final VoidCallback? onBack;
@@ -80,8 +81,8 @@ class DesktopVideoControls extends StatefulWidget {
final ShaderService? shaderService;
final VoidCallback? onShaderChanged;
/// Optional callback that returns a thumbnail URL for a given timestamp.
final String Function(Duration time)? thumbnailUrlBuilder;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final Uint8List? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream
final bool isLive;
@@ -138,13 +139,14 @@ class DesktopVideoControls extends StatefulWidget {
this.onLoadSeekTimes,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onSyncOffsetChanged,
this.serverId = '',
this.onBack,
this.canControl = true,
this.hasFirstFrame,
this.shaderService,
this.onShaderChanged,
this.thumbnailUrlBuilder,
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.isAmbientLightingEnabled = false,
@@ -451,7 +453,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.red, borderRadius: const BorderRadius.all(Radius.circular(4))),
decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))),
child: Text(
t.liveTv.live,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
@@ -484,7 +486,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onKeyEvent: _handleTimelineKeyEvent,
onFocusChange: _onFocusChange,
enabled: canInteract,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
),
const SizedBox(height: 4),
],
@@ -689,6 +691,7 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
onLoadSeekTimes: widget.onLoadSeekTimes,
onCancelAutoHide: widget.onCancelAutoHide,
onStartAutoHide: widget.onStartAutoHide,
onSyncOffsetChanged: widget.onSyncOffsetChanged,
focusNodes: _trackControlFocusNodes,
onFocusChange: _onFocusChange,
onNavigateLeft: navigateFromTrackToVolume,
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -43,8 +45,8 @@ class MobileVideoControls extends StatelessWidget {
/// Notifier for whether first video frame has rendered (shows loading state when false).
final ValueNotifier<bool>? hasFirstFrame;
/// Optional callback that returns a thumbnail URL for a given timestamp.
final String Function(Duration time)? thumbnailUrlBuilder;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final Uint8List? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream
final bool isLive;
@@ -73,7 +75,7 @@ class MobileVideoControls extends StatelessWidget {
this.onSeekToNextChapter,
this.canControl = true,
this.hasFirstFrame,
this.thumbnailUrlBuilder,
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
});
@@ -197,7 +199,7 @@ class MobileVideoControls extends StatelessWidget {
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.red, borderRadius: const BorderRadius.all(Radius.circular(4))),
decoration: const BoxDecoration(color: Colors.red, borderRadius: BorderRadius.all(Radius.circular(4))),
child: Text(
t.liveTv.live,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
@@ -225,7 +227,7 @@ class MobileVideoControls extends StatelessWidget {
horizontalLayout: false,
enabled: canControl,
showFinishTime: true,
thumbnailUrlBuilder: thumbnailUrlBuilder,
thumbnailDataBuilder: thumbnailDataBuilder,
),
),
);
@@ -35,7 +35,6 @@ class ChapterSheet extends StatefulWidget {
}
class _ChapterSheetState extends State<ChapterSheet> {
/// Get the PlexClient for chapters, or null if unavailable (offline mode)
PlexClient? _tryGetClientForChapters(BuildContext context) {
if (widget.serverId == null) return null;
@@ -49,55 +48,53 @@ class _ChapterSheetState extends State<ChapterSheet> {
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration>(
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
final currentPosition = positionSnapshot.data ?? Duration.zero;
final currentPositionMs = currentPosition.inMilliseconds;
stream: widget.player.streams.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
final currentPosition = positionSnapshot.data ?? Duration.zero;
final currentPositionMs = currentPosition.inMilliseconds;
// Find the current chapter based on position
int? currentChapterIndex;
for (int i = 0; i < widget.chapters.length; i++) {
final chapter = widget.chapters[i];
final startMs = chapter.startTimeOffset ?? 0;
final endMs =
chapter.endTimeOffset ??
(i < widget.chapters.length - 1
? widget.chapters[i + 1].startTimeOffset ?? 0
: double.maxFinite.toInt());
// Find the current chapter based on position
int? currentChapterIndex;
for (int i = 0; i < widget.chapters.length; i++) {
final chapter = widget.chapters[i];
final startMs = chapter.startTimeOffset ?? 0;
final endMs =
chapter.endTimeOffset ??
(i < widget.chapters.length - 1 ? widget.chapters[i + 1].startTimeOffset ?? 0 : double.maxFinite.toInt());
if (currentPositionMs >= startMs && currentPositionMs < endMs) {
currentChapterIndex = i;
break;
}
if (currentPositionMs >= startMs && currentPositionMs < endMs) {
currentChapterIndex = i;
break;
}
}
Widget content;
if (!widget.chaptersLoaded) {
content = const Center(child: CircularProgressIndicator());
} else if (widget.chapters.isEmpty) {
content = Center(
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
);
} else {
content = ListView.builder(
itemCount: widget.chapters.length,
itemBuilder: (context, index) {
final chapter = widget.chapters[index];
final isCurrentChapter = currentChapterIndex == index;
Widget content;
if (!widget.chaptersLoaded) {
content = const Center(child: CircularProgressIndicator());
} else if (widget.chapters.isEmpty) {
content = Center(
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
);
} else {
content = ListView.builder(
itemCount: widget.chapters.length,
itemBuilder: (context, index) {
final chapter = widget.chapters[index];
final isCurrentChapter = currentChapterIndex == index;
// Get local file path for offline chapter thumbnails
final localThumbPath = widget.serverId != null && chapter.thumb != null
? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!)
: null;
// Get local file path for offline chapter thumbnails
final localThumbPath = widget.serverId != null && chapter.thumb != null
? DownloadStorageService.instance.getArtworkPathSync(widget.serverId!, chapter.thumb!)
: null;
return FocusableListTile(
leading: chapter.thumb != null
? SizedBox(
width: 60,
height: 34,
child: Stack(
children: [
return FocusableListTile(
leading: chapter.thumb != null
? SizedBox(
width: 60,
height: 34,
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: PlexOptimizedImage.thumb(
@@ -114,48 +111,48 @@ class _ChapterSheetState extends State<ChapterSheet> {
if (isCurrentChapter)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(4)),
border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
),
),
),
],
),
)
: null,
title: Text(
chapter.label,
style: TextStyle(
color: isCurrentChapter ? Colors.blue : null,
fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal,
),
],
),
)
: null,
title: Text(
chapter.label,
style: TextStyle(
color: isCurrentChapter ? Colors.blue : null,
fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal,
),
subtitle: Text(
formatDurationTimestamp(chapter.startTime),
style: TextStyle(
color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted,
fontSize: 12,
),
),
subtitle: Text(
formatDurationTimestamp(chapter.startTime),
style: TextStyle(
color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted,
fontSize: 12,
),
trailing: isCurrentChapter
? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue)
: null,
onTap: () {
widget.player.seek(chapter.startTime);
OverlaySheetController.of(context).close();
},
);
},
);
}
return BaseVideoControlSheet(
title: t.videoControls.chapters,
icon: Symbols.video_library_rounded,
child: content,
),
trailing: isCurrentChapter
? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue)
: null,
onTap: () {
widget.player.seek(chapter.startTime);
OverlaySheetController.of(context).close();
},
);
},
);
},
);
}
return BaseVideoControlSheet(
title: t.videoControls.chapters,
icon: Symbols.video_library_rounded,
child: content,
);
},
);
}
}
@@ -62,9 +62,7 @@ class QueueSheet extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: isCurrent
? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue)
: null,
trailing: isCurrent ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) : null,
onTap: () {
onItemSelected(item);
OverlaySheetController.of(context).close();
@@ -74,11 +72,7 @@ class QueueSheet extends StatelessWidget {
);
}
return BaseVideoControlSheet(
title: t.videoControls.queue,
icon: Symbols.queue_music_rounded,
child: content,
);
return BaseVideoControlSheet(title: t.videoControls.queue, icon: Symbols.queue_music_rounded, child: content);
},
);
}
@@ -109,9 +103,9 @@ class QueueSheet extends StatelessWidget {
if (isCurrent)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
border: const Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(4)),
border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)),
),
),
),
@@ -12,6 +12,7 @@ import '../../../providers/shader_provider.dart';
import '../../../services/settings_service.dart';
import '../../../services/shader_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../focus/focusable_wrapper.dart';
import '../../../utils/formatters.dart';
import '../../../utils/platform_detector.dart';
import '../../../theme/mono_tokens.dart';
@@ -91,6 +92,15 @@ class VideoSettingsSheet extends StatefulWidget {
/// Called to toggle ambient lighting on/off (null if unsupported)
final VoidCallback? onToggleAmbientLighting;
/// Called to cancel the video controls auto-hide timer.
final VoidCallback? onCancelAutoHide;
/// Called to restart the video controls auto-hide timer.
final VoidCallback? onStartAutoHide;
/// Called when a sync offset changes (so the parent can update its state).
final void Function(String propertyName, int offset)? onSyncOffsetChanged;
const VideoSettingsSheet({
super.key,
required this.player,
@@ -102,6 +112,9 @@ class VideoSettingsSheet extends StatefulWidget {
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
this.onToggleAmbientLighting,
this.onCancelAutoHide,
this.onStartAutoHide,
this.onSyncOffsetChanged,
});
@override
@@ -115,6 +128,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
bool _enableHDR = true;
bool _showPerformanceOverlay = false;
bool _autoPlayNextEpisode = true;
bool _audioPassthrough = false;
@override
void initState() {
@@ -131,6 +145,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
_enableHDR = settings.getEnableHDR();
_showPerformanceOverlay = settings.getShowPerformanceOverlay();
_autoPlayNextEpisode = settings.getAutoPlayNextEpisode();
_audioPassthrough = settings.getAudioPassthrough();
});
}
@@ -166,13 +181,77 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
});
}
Future<void> _toggleAudioPassthrough() async {
final newValue = !_audioPassthrough;
final settings = await SettingsService.getInstance();
await settings.setAudioPassthrough(newValue);
if (!mounted) return;
setState(() {
_audioPassthrough = newValue;
});
await widget.player.setAudioPassthrough(newValue);
}
void _navigateTo(_SettingsView view) {
// Sync views open as a compact top bar instead of a sub-view
if (view == _SettingsView.audioSync || view == _SettingsView.subtitleSync) {
_openSyncBar(view);
return;
}
setState(() {
_currentView = view;
});
OverlaySheetController.maybeOf(context)?.refocus();
}
void _openSyncBar(_SettingsView view) {
final controller = OverlaySheetController.maybeOf(context);
if (controller == null) return;
final isSubtitle = view == _SettingsView.subtitleSync;
final title = isSubtitle ? t.videoSettings.subtitleSync : t.videoSettings.audioSync;
final icon = isSubtitle ? Symbols.subtitles_rounded : Symbols.sync_rounded;
final propertyName = isSubtitle ? 'sub-delay' : 'audio-delay';
final initialOffset = isSubtitle ? _subtitleSyncOffset : _audioSyncOffset;
// Created here so we can pass it as initialFocusNode to the overlay sheet,
// ensuring the slider gets focus when the bar opens. Disposed by _CompactSyncBar.
final sliderFocusNode = FocusNode(debugLabel: 'SyncSlider');
// show() with new alignment replaces the current sheet (completing the
// settings sheet future, which restarts the auto-hide timer via
// whenComplete in track_chapter_controls). Cancel it again here.
controller.show(
alignment: Alignment.topCenter,
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
initialFocusNode: sliderFocusNode,
builder: (_) => _CompactSyncBar(
title: title,
icon: icon,
player: widget.player,
propertyName: propertyName,
initialOffset: initialOffset,
sliderFocusNode: sliderFocusNode,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
if (isSubtitle) {
await settings.setSubtitleSyncOffset(offset);
} else {
await settings.setAudioSyncOffset(offset);
}
widget.onSyncOffsetChanged?.call(propertyName, offset);
},
),
).whenComplete(() {
widget.onStartAutoHide?.call();
});
// Cancel auto-hide after show() — the previous sheet's whenComplete
// fires as a microtask and restarts the timer, so schedule our cancel
// to run after that microtask.
Future.microtask(() => widget.onCancelAutoHide?.call());
}
void _navigateBack() {
setState(() {
_currentView = _SettingsView.menu;
@@ -331,6 +410,23 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
},
),
// Audio Passthrough (Desktop only)
if (isDesktop)
ListTile(
leading: AppIcon(
Symbols.surround_sound_rounded,
fill: 1,
color: _audioPassthrough ? Colors.amber : tokens(context).textMuted,
),
title: Text(t.videoSettings.audioPassthrough),
trailing: Switch(
value: _audioPassthrough,
onChanged: (_) => _toggleAudioPassthrough(),
activeThumbColor: Colors.amber,
),
onTap: _toggleAudioPassthrough,
),
// Shader Preset (MPV only)
if (widget.shaderService != null && widget.shaderService!.isSupported)
_SettingsMenuItem(
@@ -423,39 +519,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close());
}
Widget _buildAudioSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'audio-delay',
initialOffset: _audioSyncOffset,
labelText: t.videoControls.audioLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offset);
if (!mounted) return;
setState(() {
_audioSyncOffset = offset;
});
},
);
}
Widget _buildSubtitleSyncView() {
return SyncOffsetControl(
player: widget.player,
propertyName: 'sub-delay',
initialOffset: _subtitleSyncOffset,
labelText: t.videoControls.subtitlesLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setSubtitleSyncOffset(offset);
if (!mounted) return;
setState(() {
_subtitleSyncOffset = offset;
});
},
);
}
// Audio/subtitle sync views are now opened as compact top bars via _openSyncBar()
/// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn").
static String _audioBackend(String name) {
@@ -638,9 +702,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
case _SettingsView.sleep:
return _buildSleepView();
case _SettingsView.audioSync:
return _buildAudioSyncView();
case _SettingsView.subtitleSync:
return _buildSubtitleSyncView();
return _buildMenuView(); // Sync views open as top bars, fallback to menu
case _SettingsView.audioDevice:
return _buildAudioDeviceView();
case _SettingsView.shader:
@@ -650,3 +713,84 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
}
/// Compact sync bar shown at the top of the screen so subtitles remain visible.
class _CompactSyncBar extends StatefulWidget {
final String title;
final IconData icon;
final Player player;
final String propertyName;
final int initialOffset;
final Future<void> Function(int offset) onOffsetChanged;
final FocusNode sliderFocusNode;
const _CompactSyncBar({
required this.title,
required this.icon,
required this.player,
required this.propertyName,
required this.initialOffset,
required this.onOffsetChanged,
required this.sliderFocusNode,
});
@override
State<_CompactSyncBar> createState() => _CompactSyncBarState();
}
class _CompactSyncBarState extends State<_CompactSyncBar> {
final _resetFocusNode = FocusNode(debugLabel: 'SyncResetButton');
final _closeFocusNode = FocusNode(debugLabel: 'SyncCloseButton');
@override
void dispose() {
widget.sliderFocusNode.dispose();
_resetFocusNode.dispose();
_closeFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
const SizedBox(width: 16),
AppIcon(widget.icon, fill: 1, color: tokens(context).textMuted, size: 20),
const SizedBox(width: 8),
Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
Expanded(
child: SyncOffsetControl(
player: widget.player,
propertyName: widget.propertyName,
initialOffset: widget.initialOffset,
labelText: widget.title,
onOffsetChanged: widget.onOffsetChanged,
compact: true,
sliderFocusNode: widget.sliderFocusNode,
resetFocusNode: _resetFocusNode,
closeFocusNode: _closeFocusNode,
),
),
const SizedBox(width: 8),
FocusableWrapper(
focusNode: _closeFocusNode,
onSelect: () => OverlaySheetController.of(context).close(),
onNavigateLeft: () => _resetFocusNode.requestFocus(),
borderRadius: 18,
autoScroll: false,
useBackgroundFocus: true,
child: GestureDetector(
onTap: () => OverlaySheetController.of(context).close(),
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
child: AppIcon(Symbols.close_rounded, fill: 1, color: tokens(context).textMuted, size: 22),
),
),
),
const SizedBox(width: 12),
],
);
}
}
+134 -48
View File
@@ -1,5 +1,6 @@
import 'dart:async' show StreamSubscription, Timer;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/gestures.dart' show PointerSignalEvent, PointerScrollEvent;
import 'package:flutter/material.dart';
@@ -14,6 +15,7 @@ import 'package:flutter/services.dart'
PhysicalKeyboardKey,
KeyEvent,
KeyDownEvent,
KeyUpEvent,
HardwareKeyboard;
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
@@ -76,7 +78,7 @@ Widget plexVideoControlsBuilder(
ValueNotifier<bool>? controlsVisible,
ShaderService? shaderService,
VoidCallback? onShaderChanged,
String Function(Duration time)? thumbnailUrlBuilder,
Uint8List? Function(Duration time)? thumbnailDataBuilder,
bool isLive = false,
String? liveChannelName,
bool isAmbientLightingEnabled = false,
@@ -102,7 +104,7 @@ Widget plexVideoControlsBuilder(
controlsVisible: controlsVisible,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
thumbnailUrlBuilder: thumbnailUrlBuilder,
thumbnailDataBuilder: thumbnailDataBuilder,
isLive: isLive,
liveChannelName: liveChannelName,
isAmbientLightingEnabled: isAmbientLightingEnabled,
@@ -147,8 +149,8 @@ class PlexVideoControls extends StatefulWidget {
/// Called when shader preset changes
final VoidCallback? onShaderChanged;
/// Optional callback that returns a thumbnail URL for a given timestamp.
final String Function(Duration time)? thumbnailUrlBuilder;
/// Optional callback that returns thumbnail image bytes for a given timestamp.
final Uint8List? Function(Duration time)? thumbnailDataBuilder;
/// Whether this is a live TV stream (disables seek, progress, etc.)
final bool isLive;
@@ -183,7 +185,7 @@ class PlexVideoControls extends StatefulWidget {
this.controlsVisible,
this.shaderService,
this.onShaderChanged,
this.thumbnailUrlBuilder,
this.thumbnailDataBuilder,
this.isLive = false,
this.liveChannelName,
this.isAmbientLightingEnabled = false,
@@ -244,6 +246,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
int _autoSkipDelay = 5;
Timer? _autoSkipTimer;
double _autoSkipProgress = 0.0;
// Skip button dismiss state
bool _skipButtonDismissed = false;
Timer? _skipButtonDismissTimer;
// Video player navigation (use arrow keys to navigate controls)
bool _videoPlayerNavigationEnabled = false;
// Performance overlay
@@ -353,15 +358,23 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _updateCurrentMarker(PlexMarker? foundMarker) {
setState(() {
_currentMarker = foundMarker;
_skipButtonDismissed = false;
});
if (foundMarker == null) {
_cancelAutoSkipTimer();
_cancelSkipButtonDismissTimer();
return;
}
_startAutoSkipTimer(foundMarker);
// Auto-skip OFF: dismiss button after 7s if no interaction
// Auto-skip ON: button stays until controls hide
if (!_shouldAutoSkipForMarker(foundMarker)) {
_startSkipButtonDismissTimer();
}
// Auto-focus skip button on TV when marker appears (only in keyboard/TV mode, if controls hidden)
if (PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -405,10 +418,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
void _skipMarker() {
if (_currentMarker != null) {
final endTime = _currentMarker!.endTime;
setState(() {
_currentMarker = null;
});
widget.player.seek(endTime);
widget.onSeekCompleted?.call(endTime);
}
_cancelAutoSkipTimer();
_cancelSkipButtonDismissTimer();
}
void _startAutoSkipTimer(PlexMarker marker) {
@@ -455,6 +472,24 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
/// Starts/restarts the skip button dismiss timer. When it fires, hides the
/// button and cancels any active auto-skip countdown.
void _startSkipButtonDismissTimer() {
_skipButtonDismissTimer?.cancel();
_skipButtonDismissTimer = Timer(const Duration(seconds: 7), () {
if (!mounted || _currentMarker == null) return;
setState(() {
_skipButtonDismissed = true;
});
_cancelAutoSkipTimer();
});
}
void _cancelSkipButtonDismissTimer() {
_skipButtonDismissTimer?.cancel();
_skipButtonDismissTimer = null;
}
/// Perform the appropriate skip action based on marker type and next episode availability
void _performAutoSkip() {
if (_currentMarker == null) return;
@@ -471,9 +506,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
/// Check if auto-skip should be active for the current marker
bool _shouldAutoSkipForMarker(PlexMarker marker) {
return (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro);
}
bool _shouldShowAutoSkip() {
if (_currentMarker == null) return false;
return (_currentMarker!.isCredits && _autoSkipCredits) || (!_currentMarker!.isCredits && _autoSkipIntro);
return _shouldAutoSkipForMarker(_currentMarker!);
}
Future<void> _loadSeekTimes() async {
@@ -565,6 +604,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
_hideTimer?.cancel();
_feedbackTimer?.cancel();
_autoSkipTimer?.cancel();
_skipButtonDismissTimer?.cancel();
_singleTapTimer?.cancel();
_seekThrottle.cancel();
_playingSubscription?.cancel();
@@ -648,21 +688,30 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
if (!mounted || !_showControls) return;
setState(() {
_showControls = false;
// Dismiss skip button with controls — after this it only re-appears with controls
if (_currentMarker != null) {
_skipButtonDismissed = true;
}
});
_cancelSkipButtonDismissTimer();
widget.controlsVisible?.value = false;
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
// Immediately try to reclaim focus (important for TV where global handler
// won't fire if _focusNode lost focus)
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
// Reclaim focus so the global key handler stays active for TV dpad,
// but skip if an overlay sheet owns focus — stealing it would break
// sheet navigation (e.g. the compact sync bar).
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (!sheetOpen) {
if (!_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_focusNode.hasFocus) {
_focusNode.requestFocus();
}
});
}
}
void _startHideTimer() {
@@ -742,21 +791,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = _showControls;
// Cancel auto-skip on any tap, not just when controls become visible
_cancelAutoSkipTimer();
if (_showControls) {
_hideControls();
} else {
setState(() {
_showControls = true;
});
widget.controlsVisible?.value = true;
_startHideTimer();
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
}
// On macOS, hide/show traffic lights with controls
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
// Cancel auto-skip on any tap
_cancelAutoSkipTimer();
}
void _toggleRotationLock() async {
@@ -918,6 +966,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onSyncOffsetChanged: (propertyName, offset) {
setState(() {
if (propertyName == 'sub-delay') {
_subtitleSyncOffset = offset;
} else {
_audioSyncOffset = offset;
}
});
},
serverId: widget.metadata.serverId ?? '',
canControl: widget.canControl,
isLive: widget.isLive,
@@ -1270,7 +1327,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16),
const AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16),
const SizedBox(width: 4),
const Text(
'2x',
@@ -1288,6 +1345,14 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
}
/// Exit fullscreen if the window is actually fullscreen (async check).
/// Used by ESC handler on Windows/Linux to avoid relying on _isFullscreen flag.
Future<void> _exitFullscreenIfNeeded() async {
if (await windowManager.isFullScreen()) {
await FullscreenStateManager().exitFullscreen();
}
}
/// Initialize always-on-top state from window manager (desktop only)
Future<void> _initAlwaysOnTopState() async {
final isOnTop = await windowManager.isAlwaysOnTop();
@@ -1370,8 +1435,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
bool _handleGlobalKeyEvent(KeyEvent event) {
if (!mounted) return false;
// TV back key fallback — Focus.onKeyEvent won't fire if _focusNode lost focus
if (PlatformDetector.isTV() && event.logicalKey.isBackKey) {
// Back key fallback when _focusNode lost focus (TV, or desktop with nav on).
// Focus.onKeyEvent won't fire if _focusNode lost focus, so handle ESC here.
if ((_videoPlayerNavigationEnabled || PlatformDetector.isTV()) && event.logicalKey.isBackKey) {
if (!_focusNode.hasFocus) {
// Skip if an overlay sheet is open — the sheet's FocusScope handles
// back keys via its own onKeyEvent. Without this check, this global
@@ -1407,6 +1473,20 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
// (e.g. after controls auto-hide). The !hasFocus guard prevents
// double-handling when the Focus onKeyEvent already processes the event.
if (!_focusNode.hasFocus && _keyboardService != null) {
// On Windows/Linux with navigation off, ESC only exits fullscreen —
// never exits the player. Intercept before the keyboard shortcuts
// service which would call onBack and pop the route.
// Skip if an overlay sheet is open — let the sheet handle ESC.
if (!_videoPlayerNavigationEnabled && (Platform.isWindows || Platform.isLinux) && event.logicalKey.isBackKey) {
final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false;
if (!sheetOpen) {
if (event is KeyUpEvent) {
_exitFullscreenIfNeeded();
}
_focusNode.requestFocus();
return true;
}
}
final result = _keyboardService!.handleVideoPlayerKeyEvent(
event,
widget.player,
@@ -1494,16 +1574,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
}
if (_showControls) {
setState(() {
_showControls = false;
});
// Notify parent of visibility change (for popup positioning)
widget.controlsVisible?.value = false;
// Return focus to the main focus node
_focusNode.requestFocus();
if (Platform.isMacOS) {
_updateTrafficLightVisibility();
}
_hideControls();
}
}
@@ -1521,12 +1592,18 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
focusNode: _focusNode,
autofocus: true,
onKeyEvent: (node, event) {
final backResult = handleBackKeyAction(event, () {
// On Windows/Linux with navigation off, ESC first exits fullscreen
if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) {
_toggleFullscreen();
return;
// On Windows/Linux with navigation off, ESC only exits fullscreen —
// never exits the player. Consume all back key events and check
// actual window state asynchronously.
if (!_videoPlayerNavigationEnabled &&
(Platform.isWindows || Platform.isLinux) &&
event.logicalKey.isBackKey) {
if (event is KeyUpEvent) {
_exitFullscreenIfNeeded();
}
return KeyEventResult.handled;
}
final backResult = handleBackKeyAction(event, () {
if (!_showControls) {
_showControlsWithFocus();
return;
@@ -1809,7 +1886,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onSeekToNextChapter: _seekToNextChapter,
canControl: widget.canControl,
hasFirstFrame: widget.hasFirstFrame,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
),
@@ -1836,8 +1913,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
),
// Speed indicator overlay for long-press 2x
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
// Skip intro/credits button
if (_currentMarker != null)
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
if (_currentMarker != null && (!_skipButtonDismissed || _showControls))
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
@@ -1916,6 +1993,15 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
},
onCancelAutoHide: () => _hideTimer?.cancel(),
onStartAutoHide: _startHideTimer,
onSyncOffsetChanged: (propertyName, offset) {
setState(() {
if (propertyName == 'sub-delay') {
_subtitleSyncOffset = offset;
} else {
_audioSyncOffset = offset;
}
});
},
serverId: widget.metadata.serverId ?? '',
onBack: widget.onBack,
canControl: widget.canControl,
@@ -1924,7 +2010,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null,
shaderService: widget.shaderService,
onShaderChanged: widget.onShaderChanged,
thumbnailUrlBuilder: widget.thumbnailUrlBuilder,
thumbnailDataBuilder: widget.thumbnailDataBuilder,
isLive: widget.isLive,
liveChannelName: widget.liveChannelName,
isAmbientLightingEnabled: widget.isAmbientLightingEnabled,
@@ -4,7 +4,7 @@ import 'dart:io' show ProcessInfo;
import 'package:flutter/scheduler.dart';
import '../../../../mpv/mpv.dart';
import '../../../../mpv/player/player_android.dart';
import '../../../../mpv/player/platform/player_android.dart';
import '../../../../utils/app_logger.dart';
import 'performance_stats.dart';

Some files were not shown because too many files have changed in this diff Show More