Merge remote-tracking branch 'upstream/main' into feature/expand-episodes
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
+1
-1
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' · ');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user