Merge remote-tracking branch 'upstream/main' into feature/more-detail-in-media-screen

This commit is contained in:
Tazio
2025-11-11 14:42:40 +01:00
49 changed files with 4296 additions and 618 deletions
+49
View File
@@ -0,0 +1,49 @@
# Contributing
## Getting Started
1. Fork and clone the repository
2. Run `flutter pub get` to install dependencies
3. Run `dart run build_runner build` to generate code
4. Start developing!
## Development
- Follow Dart/Flutter conventions
- Run `flutter analyze` before submitting
- Test your changes thoroughly
## Internationalization (i18n)
This project uses `slang` for internationalization with JSON files.
### Adding New Strings
1. Add your string to `lib/i18n/strings.i18n.json`:
```json
{
"section": {
"myNewString": "My new text"
}
}
```
2. Run `dart run slang` to regenerate translation files
3. Use in your code:
```dart
Text(t.section.myNewString)
```
### Adding New Languages
1. Create new JSON file: `lib/i18n/strings_[locale].i18n.json`
2. Copy structure from `strings.i18n.json` and translate values
3. Run `dart run slang` to regenerate files
### Guidelines
- Organize strings logically in nested objects
- Use camelCase for keys
- Keep strings concise and clear
- Always run `dart run slang` after changes
+119
View File
@@ -10,6 +10,7 @@ import '../models/plex_library.dart';
import '../models/plex_media_info.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../models/plex_video_playback_data.dart';
import '../models/plex_sort.dart';
import '../utils/app_logger.dart';
@@ -639,6 +640,124 @@ class PlexClient {
return [];
}
/// Get consolidated video playback data (URL, media info, and versions) in a single API call
/// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions()
/// to reduce redundant API calls during video playback initialization.
Future<PlexVideoPlaybackData> getVideoPlaybackData(
String ratingKey, {
int mediaIndex = 0,
}) async {
final response = await _dio.get('/library/metadata/$ratingKey');
final metadataJson = _getFirstMetadataJson(response);
String? videoUrl;
PlexMediaInfo? mediaInfo;
List<PlexMediaVersion> availableVersions = [];
if (metadataJson != null &&
metadataJson['Media'] != null &&
(metadataJson['Media'] as List).isNotEmpty) {
final mediaList = metadataJson['Media'] as List;
// Parse available media versions first
availableVersions = mediaList
.map(
(media) => PlexMediaVersion.fromJson(media as Map<String, dynamic>),
)
.toList();
// Ensure the requested index is valid
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
mediaIndex = 0;
}
final media = mediaList[mediaIndex];
if (media['Part'] != null && (media['Part'] as List).isNotEmpty) {
final part = media['Part'][0];
final partKey = part['key'] as String?;
if (partKey != null) {
// Get video URL
videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}';
// Parse streams (audio and subtitle tracks) for media info
final streams = part['Stream'] as List<dynamic>? ?? [];
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
if (streamType == 2) {
// Audio track
audioTracks.add(
PlexAudioTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
channels: stream['channels'] as int?,
selected: stream['selected'] == 1,
),
);
} else if (streamType == 3) {
// Subtitle track
subtitleTracks.add(
PlexSubtitleTrack(
id: stream['id'] as int,
index: stream['index'] as int?,
codec: stream['codec'] as String?,
language: stream['language'] as String?,
languageCode: stream['languageCode'] as String?,
title: stream['title'] as String?,
displayTitle: stream['displayTitle'] as String?,
selected: stream['selected'] == 1,
forced: stream['forced'] == 1,
key: stream['key'] as String?,
),
);
}
}
// Parse chapters
final chapters = <PlexChapter>[];
if (metadataJson['Chapter'] != null) {
final chapterList = metadataJson['Chapter'] as List<dynamic>;
for (var chapter in chapterList) {
chapters.add(
PlexChapter(
id: chapter['id'] as int,
index: chapter['index'] as int?,
startTimeOffset: chapter['startTimeOffset'] as int?,
endTimeOffset: chapter['endTimeOffset'] as int?,
title: chapter['title'] as String?,
thumb: chapter['thumb'] as String?,
),
);
}
}
// Create media info
mediaInfo = PlexMediaInfo(
videoUrl: videoUrl,
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
chapters: chapters,
);
}
}
}
return PlexVideoPlaybackData(
videoUrl: videoUrl,
mediaInfo: mediaInfo,
availableVersions: availableVersions,
);
}
/// Get file information for a media item
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
try {
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
{
"app": {
"title": "Plezy",
"loading": "Loading..."
},
"auth": {
"signInWithPlex": "Sign in with Plex",
"showQRCode": "Show QR Code",
"cancel": "Cancel",
"authenticate": "Authenticate",
"retry": "Retry",
"debugEnterToken": "Debug: Enter Plex Token",
"plexTokenLabel": "Plex Auth Token",
"plexTokenHint": "Enter your Plex.tv token",
"authenticationTimeout": "Authentication timed out. Please try again.",
"scanQRCodeInstruction": "Scan this QR code with a device logged into Plex to authenticate.",
"waitingForAuth": "Waiting for authentication...\nPlease complete sign-in in your browser."
},
"common": {
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"clear": "Clear",
"reset": "Reset",
"later": "Later",
"submit": "Submit",
"confirm": "Confirm",
"retry": "Retry",
"playNow": "Play Now",
"logout": "Logout",
"online": "Online",
"offline": "Offline",
"owned": "Owned",
"shared": "Shared",
"current": "CURRENT",
"unknown": "Unknown",
"refresh": "Refresh",
"yes": "Yes",
"no": "No",
"server": "Server"
},
"screens": {
"licenses": "Licenses",
"selectServer": "Select Server",
"switchProfile": "Switch Profile",
"subtitleStyling": "Subtitle Styling",
"search": "Search",
"logs": "Logs"
},
"update": {
"available": "Update Available",
"versionAvailable": "Version ${version} is available",
"currentVersion": "Current: ${version}",
"skipVersion": "Skip This Version",
"viewRelease": "View Release",
"latestVersion": "You are on the latest version",
"checkFailed": "Failed to check for updates"
},
"settings": {
"title": "Settings",
"language": "Language",
"theme": "Theme",
"appearance": "Appearance",
"videoPlayback": "Video Playback",
"shufflePlay": "Shuffle Play",
"advanced": "Advanced",
"useSeasonPostersDescription": "Show season poster instead of series poster for episodes",
"showHeroSectionDescription": "Display featured content carousel on home screen",
"secondsLabel": "Seconds",
"minutesLabel": "Minutes",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Enter duration (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Follow system settings",
"lightTheme": "Light",
"darkTheme": "Dark",
"libraryDensity": "Library Density",
"compact": "Compact",
"compactDescription": "Smaller cards, more items visible",
"normal": "Normal",
"normalDescription": "Default size",
"comfortable": "Comfortable",
"comfortableDescription": "Larger cards, fewer items visible",
"viewMode": "View Mode",
"gridView": "Grid",
"gridViewDescription": "Display items in a grid layout",
"listView": "List",
"listViewDescription": "Display items in a list layout",
"useSeasonPosters": "Use Season Posters",
"showHeroSection": "Show Hero Section",
"hardwareDecoding": "Hardware Decoding",
"hardwareDecodingDescription": "Use hardware acceleration when available",
"bufferSize": "Buffer Size",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Subtitle Styling",
"subtitleStylingDescription": "Customize subtitle appearance",
"smallSkipDuration": "Small Skip Duration",
"largeSkipDuration": "Large Skip Duration",
"secondsUnit": "${seconds} seconds",
"defaultSleepTimer": "Default Sleep Timer",
"minutesUnit": "${minutes} minutes",
"unwatchedOnly": "Unwatched Only",
"unwatchedOnlyDescription": "Only include unwatched episodes in shuffle queue",
"shuffleOrderNavigation": "Shuffle Order Navigation",
"shuffleOrderNavigationDescription": "Next/previous buttons follow shuffled order",
"loopShuffleQueue": "Loop Shuffle Queue",
"loopShuffleQueueDescription": "Restart queue when reaching the end",
"videoPlayerControls": "Video Player Controls",
"keyboardShortcuts": "Keyboard Shortcuts",
"keyboardShortcutsDescription": "Customize keyboard shortcuts",
"debugLogging": "Debug Logging",
"debugLoggingDescription": "Enable detailed logging for troubleshooting",
"viewLogs": "View Logs",
"viewLogsDescription": "View application logs",
"clearCache": "Clear Cache",
"clearCacheDescription": "This will clear all cached images and data. The app may take longer to load content after clearing the cache.",
"clearCacheSuccess": "Cache cleared successfully",
"resetSettings": "Reset Settings",
"resetSettingsDescription": "This will reset all settings to their default values. This action cannot be undone.",
"resetSettingsSuccess": "Settings reset successfully",
"shortcutsReset": "Shortcuts reset to defaults",
"about": "About",
"aboutDescription": "App information and licenses",
"updates": "Updates",
"updateAvailable": "Update Available",
"checkForUpdates": "Check for Updates",
"validationErrorEnterNumber": "Please enter a valid number",
"validationErrorDuration": "Duration must be between ${min} and ${max} ${unit}",
"shortcutAlreadyAssigned": "Shortcut already assigned to ${action}",
"shortcutUpdated": "Shortcut updated for ${action}"
},
"search": {
"hint": "Search movies, shows, music...",
"tryDifferentTerm": "Try a different search term"
},
"hotkeys": {
"setShortcutFor": "Set Shortcut for ${actionName}",
"clearShortcut": "Clear shortcut"
},
"pinEntry": {
"enterPin": "Enter PIN",
"showPin": "Show PIN",
"hidePin": "Hide PIN"
},
"fileInfo": {
"title": "File Info",
"video": "Video",
"audio": "Audio",
"file": "File",
"advanced": "Advanced",
"codec": "Codec",
"resolution": "Resolution",
"bitrate": "Bitrate",
"frameRate": "Frame Rate",
"aspectRatio": "Aspect Ratio",
"profile": "Profile",
"bitDepth": "Bit Depth",
"colorSpace": "Color Space",
"colorRange": "Color Range",
"colorPrimaries": "Color Primaries",
"chromaSubsampling": "Chroma Subsampling",
"channels": "Channels",
"path": "Path",
"size": "Size",
"container": "Container",
"duration": "Duration",
"optimizedForStreaming": "Optimized for Streaming",
"has64bitOffsets": "64-bit Offsets"
},
"mediaMenu": {
"markAsWatched": "Mark as Watched",
"markAsUnwatched": "Mark as Unwatched",
"goToSeries": "Go to series",
"goToSeason": "Go to season",
"shufflePlay": "Shuffle Play",
"fileInfo": "File Info"
},
"tooltips": {
"shufflePlay": "Shuffle play",
"markAsWatched": "Mark as watched",
"markAsUnwatched": "Mark as unwatched"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Subtitles",
"resetToZero": "Reset to 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} plays later",
"playsEarlier": "${label} plays earlier",
"noOffset": "No offset",
"letterbox": "Letterbox",
"fillScreen": "Fill screen",
"stretch": "Stretch",
"lockRotation": "Lock rotation",
"unlockRotation": "Unlock rotation"
},
"userStatus": {
"admin": "Admin",
"restricted": "Restricted",
"protected": "Protected"
},
"messages": {
"markedAsWatched": "Marked as watched",
"markedAsUnwatched": "Marked as unwatched",
"errorLoading": "Error: ${error}",
"fileInfoNotAvailable": "File information not available",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
"errorLoadingSeason": "Error loading season",
"musicNotSupported": "Music playback is not yet supported",
"logsCleared": "Logs cleared",
"logsCopied": "Logs copied to clipboard",
"noLogsAvailable": "No logs available",
"libraryScanning": "Scanning \"${title}\"...",
"libraryScanStarted": "Library scan started for \"${title}\"",
"libraryScanFailed": "Failed to scan library: ${error}",
"metadataRefreshing": "Refreshing metadata for \"${title}\"...",
"metadataRefreshStarted": "Metadata refresh started for \"${title}\"",
"metadataRefreshFailed": "Failed to refresh metadata: ${error}",
"noPlexToken": "No Plex token found. Please login again.",
"logoutConfirm": "Are you sure you want to logout?",
"noSeasonsFound": "No seasons found",
"noEpisodesFound": "No episodes found in first season",
"noEpisodesFoundGeneral": "No episodes found",
"noResultsFound": "No results found",
"sleepTimerSet": "Sleep timer set for ${label}",
"failedToSwitchProfile": "Failed to switch to ${displayName}"
},
"profile": {
"noUsersAvailable": "No users available"
},
"subtitlingStyling": {
"stylingOptions": "Styling Options",
"fontSize": "Font Size",
"textColor": "Text Color",
"borderSize": "Border Size",
"borderColor": "Border Color",
"backgroundOpacity": "Background Opacity",
"backgroundColor": "Background Color"
},
"dialog": {
"confirmAction": "Confirm Action",
"areYouSure": "Are you sure you want to perform this action?",
"cancel": "Cancel",
"playNow": "Play Now"
},
"discover": {
"title": "Discover",
"switchProfile": "Switch Profile",
"switchServer": "Switch Server",
"logout": "Logout",
"noContentAvailable": "No content available",
"addMediaToLibraries": "Add some media to your libraries",
"continueWatching": "Continue Watching",
"recentlyAdded": "Recently Added",
"play": "Play",
"resume": "Resume",
"playEpisode": "Play S${season}, E${episode}",
"resumeEpisode": "Resume S${season}, E${episode}",
"pause": "Pause",
"overview": "Overview",
"episodeCount": "${count} episodes",
"watchedProgress": "${watched}/${total} watched",
"movie": "Movie",
"tvShow": "TV Show",
"minutesLeft": "${minutes} min left"
},
"errors": {
"searchFailed": "Search failed: ${error}",
"connectionTimeout": "Connection timeout while loading ${context}",
"connectionFailed": "Unable to connect to Plex server",
"failedToLoad": "Failed to load ${context}: ${error}",
"noClientAvailable": "No client available",
"authenticationFailed": "Authentication failed: ${error}",
"couldNotLaunchUrl": "Could not launch auth URL",
"pleaseEnterToken": "Please enter a token",
"invalidToken": "Invalid token",
"failedToVerifyToken": "Failed to verify token: ${error}",
"failedToSwitchProfile": "Failed to switch to ${displayName}",
"connectionFailedGeneric": "Connection failed"
},
"libraries": {
"title": "Libraries",
"scanLibraryFiles": "Scan Library Files",
"scanLibrary": "Scan Library",
"analyze": "Analyze",
"analyzeLibrary": "Analyze Library",
"refreshMetadata": "Refresh Metadata",
"emptyTrash": "Empty Trash",
"emptyingTrash": "Emptying trash for \"${title}\"...",
"trashEmptied": "Trash emptied for \"${title}\"",
"failedToEmptyTrash": "Failed to empty trash: ${error}",
"analyzing": "Analyzing \"${title}\"...",
"analysisStarted": "Analysis started for \"${title}\"",
"failedToAnalyze": "Failed to analyze library: ${error}",
"noLibrariesFound": "No libraries found",
"thisLibraryIsEmpty": "This library is empty",
"all": "All",
"clearAll": "Clear All",
"scanLibraryConfirm": "Are you sure you want to scan \"${title}\"?",
"analyzeLibraryConfirm": "Are you sure you want to analyze \"${title}\"?",
"refreshMetadataConfirm": "Are you sure you want to refresh metadata for \"${title}\"?",
"emptyTrashConfirm": "Are you sure you want to empty trash for \"${title}\"?",
"manageLibraries": "Manage Libraries",
"sort": "Sort",
"sortBy": "Sort By",
"filters": "Filters",
"loadingLibraryWithCount": "Loading library... (${count} items loaded)",
"confirmActionMessage": "Are you sure you want to perform this action?",
"showLibrary": "Show library",
"hideLibrary": "Hide library",
"libraryOptions": "Library options"
},
"about": {
"title": "About",
"openSourceLicenses": "Open Source Licenses",
"versionLabel": "Version ${version}",
"appDescription": "A beautiful Plex client for Flutter",
"viewLicensesDescription": "View licenses of third-party libraries"
},
"serverSelection": {
"connectingToServer": "Connecting to server...",
"serverDebugCopied": "Server debug data copied to clipboard",
"copyDebugData": "Copy Debug Data",
"noServersFound": "No servers found",
"malformedServerData": "Found ${count} server(s) with malformed data. No valid servers available.",
"incompleteServerInfo": "Some servers have incomplete information and were skipped. Please check your Plex.tv account.",
"incompleteConnectionInfo": "Server connection information is incomplete. Please try again.",
"malformedServerInfo": "Server information is malformed: ${message}",
"networkConnectionFailed": "Network connection failed. Please check your internet connection and try again.",
"authenticationFailed": "Authentication failed. Please sign in again.",
"plexServiceUnavailable": "Plex service unavailable. Please try again later.",
"failedToLoadServers": "Failed to load servers: ${error}"
},
"hubDetail": {
"title": "Title",
"releaseYear": "Release Year",
"dateAdded": "Date Added",
"rating": "Rating",
"noItemsFound": "No items found"
},
"logs": {
"title": "Logs",
"clearLogs": "Clear Logs",
"copyLogs": "Copy Logs",
"exportLogs": "Export Logs",
"noLogsToShow": "No logs to show",
"error": "Error:",
"stackTrace": "Stack Trace:"
},
"licenses": {
"relatedPackages": "Related Packages",
"license": "License",
"licenseNumber": "License ${number}",
"licensesCount": "${count} licenses"
},
"navigation": {
"home": "Home",
"search": "Search",
"libraries": "Libraries",
"settings": "Settings"
}
}
+365
View File
@@ -0,0 +1,365 @@
{
"app": {
"title": "Plezy",
"loading": "Caricamento..."
},
"auth": {
"signInWithPlex": "Accedi con Plex",
"showQRCode": "Mostra QR Code",
"cancel": "Cancella",
"authenticate": "Autenticazione",
"retry": "Riprova",
"debugEnterToken": "Debug: Inserisci Token Plex",
"plexTokenLabel": "Token Auth Plex",
"plexTokenHint": "Inserisci il tuo token di Plex.tv",
"authenticationTimeout": "Autenticazione scaduta. Riprova.",
"scanQRCodeInstruction": "Scansiona questo QR code con un dispositivo connesso a Plex per autenticarti.",
"waitingForAuth": "In attesa di autenticazione...\nCompleta l'accesso dal tuo browser."
},
"common": {
"cancel": "Cancella",
"save": "Salva",
"close": "Chiudi",
"clear": "Pulisci",
"reset": "Ripristina",
"later": "Più tardi",
"submit": "Invia",
"confirm": "Conferma",
"retry": "Riprova",
"playNow": "Riproduci ora",
"logout": "Disconnetti",
"online": "Online",
"offline": "Offline",
"owned": "Di proprietà",
"shared": "Condiviso",
"current": "CORRENTE",
"unknown": "Sconosciuto",
"refresh": "Aggiorna",
"yes": "Sì",
"no": "No",
"server": "Server"
},
"screens": {
"licenses": "Licenze",
"selectServer": "Seleziona server",
"switchProfile": "Cambia profilo",
"subtitleStyling": "Stile sottotitoli",
"search": "Cerca",
"logs": "Logs"
},
"update": {
"available": "Aggiornamento disponibile",
"versionAvailable": "Versione ${version} disponibile",
"currentVersion": "Corrente: ${version}",
"skipVersion": "Salta questa versione",
"viewRelease": "Visualizza dettagli release",
"latestVersion": "La versione installata è l'ultima disponibile",
"checkFailed": "Impossibile controllare gli aggiornamenti"
},
"settings": {
"title": "Impostazioni",
"language": "Lingua",
"theme": "Tema",
"appearance": "Aspetto",
"videoPlayback": "Riproduzione video",
"shufflePlay": "Riproduzione casuale",
"advanced": "Avanzate",
"useSeasonPostersDescription": "Mostra il poster della stagione invece del poster della serie per gli episodi",
"showHeroSectionDescription": "Visualizza il carosello dei contenuti in primo piano sulla schermata iniziale",
"secondsLabel": "Secondi",
"minutesLabel": "Minuti",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Inserisci durata (${min}-${max})",
"systemTheme": "Sistema",
"systemThemeDescription": "Segui le impostazioni di sistema",
"lightTheme": "Chiaro",
"darkTheme": "Scuro",
"libraryDensity": "Densità libreria",
"compact": "Compatta",
"compactDescription": "Schede più piccole, più elementi visibili",
"normal": "Normale",
"normalDescription": "Dimensione predefinita",
"comfortable": "Comoda",
"comfortableDescription": "Schede più grandi, meno elementi visibili",
"viewMode": "Modalità di visualizzazione",
"gridView": "Griglia",
"gridViewDescription": "Visualizza gli elementi in un layout a griglia",
"listView": "Elenco",
"listViewDescription": "Visualizza gli elementi in un layout a elenco",
"useSeasonPosters": "Usa poster delle stagioni",
"showHeroSection": "Mostra sezione principale",
"hardwareDecoding": "Decodifica Hardware",
"hardwareDecodingDescription": "Utilizza l'accelerazione hardware quando disponibile",
"bufferSize": "Dimensione buffer",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Stile sottotitoli",
"subtitleStylingDescription": "Personalizza l'aspetto dei sottotitoli",
"smallSkipDuration": "Durata skip breve",
"largeSkipDuration": "Durata skip lungo",
"secondsUnit": "${seconds} secondi",
"defaultSleepTimer": "Timer spegnimento predefinito",
"minutesUnit": "${minutes} minuti",
"unwatchedOnly": "Solo non guardati",
"unwatchedOnlyDescription": "Includi solo gli episodi non guardati nella coda di riproduzione casuale",
"shuffleOrderNavigation": "Navigazione in ordine casuale",
"shuffleOrderNavigationDescription": "I pulsanti Avanti/Indietro seguono l'ordine casuale",
"loopShuffleQueue": "Coda di riproduzione casuale in loop",
"loopShuffleQueueDescription": "Riavvia la coda quando raggiungi la fine",
"videoPlayerControls": "Controlli del lettore video",
"keyboardShortcuts": "Scorciatoie da tastiera",
"keyboardShortcutsDescription": "Personalizza le scorciatoie da tastiera",
"debugLogging": "Log di debug",
"debugLoggingDescription": "Abilita il logging dettagliato per la risoluzione dei problemi",
"viewLogs": "Visualizza log",
"viewLogsDescription": "Visualizza i log dell'applicazione",
"clearCache": "Svuota cache",
"clearCacheDescription": "Questa opzione cancellerà tutte le immagini e i dati memorizzati nella cache. Dopo aver cancellato la cache, l'app potrebbe impiegare più tempo per caricare i contenuti.",
"clearCacheSuccess": "Cache cancellata correttamente",
"resetSettings": "Ripristina impostazioni",
"resetSettingsDescription": "Questa opzione ripristinerà tutte le impostazioni ai valori predefiniti. Non può essere annullata.",
"resetSettingsSuccess": "Impostazioni ripristinate correttamente",
"shortcutsReset": "Scorciatoie ripristinate alle impostazioni predefinite",
"about": "Informazioni",
"aboutDescription": "Informazioni sull'app e le licenze",
"updates": "Aggiornamenti",
"updateAvailable": "Aggiornamento disponibile",
"checkForUpdates": "Controlla aggiornamenti",
"validationErrorEnterNumber": "Inserisci un numero valido",
"validationErrorDuration": "la durata deve essere compresa tra ${min} e ${max} ${unit}",
"shortcutAlreadyAssigned": "Scorciatoia già assegnata a ${action}",
"shortcutUpdated": "Scorciatoia aggiornata per ${action}"
},
"search": {
"hint": "Cerca film. spettacoli, musica...",
"tryDifferentTerm": "Prova altri termini di ricerca"
},
"hotkeys": {
"setShortcutFor": "Imposta scorciatoia per ${actionName}",
"clearShortcut": "Elimina scorciatoia"
},
"pinEntry": {
"enterPin": "Inserisci PIN",
"showPin": "Mostra PIN",
"hidePin": "Nascondi PIN"
},
"fileInfo": {
"title": "Info sul file",
"video": "Video",
"audio": "Audio",
"file": "File",
"advanced": "Avanzate",
"codec": "Codec",
"resolution": "Risoluzione",
"bitrate": "Bitrate",
"frameRate": "Frame Rate",
"aspectRatio": "Aspect Ratio",
"profile": "Profilo",
"bitDepth": "Profondità colore",
"colorSpace": "Spazio colore",
"colorRange": "Gamma colori",
"colorPrimaries": "Colori primari",
"chromaSubsampling": "Sottocampionamento cromatico",
"channels": "Canali",
"path": "Percorso",
"size": "Dimensione",
"container": "Contenitore",
"duration": "Durata",
"optimizedForStreaming": "Ottimizzato per lo streaming",
"has64bitOffsets": "Offset a 64-bit"
},
"mediaMenu": {
"markAsWatched": "Segna come visto",
"markAsUnwatched": "Segna come non visto",
"goToSeries": "Vai alle serie",
"goToSeason": "Vai alla stagione",
"shufflePlay": "Riproduzione casuale",
"fileInfo": "Info sul file"
},
"tooltips": {
"shufflePlay": "Riproduzione casuale",
"markAsWatched": "Segna come visto",
"markAsUnwatched": "Segna come non visto"
},
"videoControls": {
"audioLabel": "Audio",
"subtitlesLabel": "Sottotitoli",
"resetToZero": "Riporta a 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} riprodotto dopo",
"playsEarlier": "${label} riprodotto prima",
"noOffset": "No offset",
"letterbox": "Letterbox",
"fillScreen": "Riempi schermo",
"stretch": "Allunga",
"lockRotation": "Blocca rotazione",
"unlockRotation": "Sblocca rotazione"
},
"userStatus": {
"admin": "Admin",
"restricted": "Limitato",
"protected": "Protetto"
},
"messages": {
"markedAsWatched": "Segna come visto",
"markedAsUnwatched": "Segna come non visto",
"errorLoading": "Errore: ${error}",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
"errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}",
"errorLoadingSeries": "Errore caricamento serie",
"errorLoadingSeason": "Errore caricamento stagione",
"musicNotSupported": "La riproduzione musicale non è ancora supportata",
"logsCleared": "Log eliminati",
"logsCopied": "Log copiati negli appunti",
"noLogsAvailable": "Nessun log disponibile",
"libraryScanning": "Scansione \"${title}\"...",
"libraryScanStarted": "Scansione libreria iniziata per \"${title}\"",
"libraryScanFailed": "Impossibile eseguire scansione della libreria: ${error}",
"metadataRefreshing": "Aggiornamento metadati per \"${title}\"...",
"metadataRefreshStarted": "Aggiornamento metadati per \"${title}\"",
"metadataRefreshFailed": "Errore aggiornamento metadati: ${error}",
"noPlexToken": "Nessun token Plex trovato. Riesegui l'accesso.",
"logoutConfirm": "Sei sicuro di volerti disconnettere?",
"noSeasonsFound": "Nessuna stagione trovata",
"noEpisodesFound": "Nessun episodio trovato nella prima stagione",
"noEpisodesFoundGeneral": "Nessun episodio trovato",
"noResultsFound": "Nessun risultato",
"sleepTimerSet": "Imposta timer spegnimento per ${label}",
"failedToSwitchProfile": "Impossibile passare a ${displayName}"
},
"profile": {
"noUsersAvailable": "Nessun utente disponibile"
},
"subtitlingStyling": {
"stylingOptions": "Opzioni stile",
"fontSize": "Dimensione",
"textColor": "Colore testo",
"borderSize": "Dimensione bordo",
"borderColor": "Colore bordo",
"backgroundOpacity": "Opacità sfondo",
"backgroundColor": "Colore sfondo"
},
"dialog": {
"confirmAction": "Conferma azione",
"areYouSure": "Sei sicuro di voler eseguire questa azione?",
"cancel": "Cancella",
"playNow": "Riproduci ora"
},
"discover": {
"title": "Discover",
"switchProfile": "Cambia profilo",
"switchServer": "Cambia server",
"logout": "Disconnetti",
"noContentAvailable": "Nessun contenuto disponibile",
"addMediaToLibraries": "Aggiungi alcuni file multimediali alle tue librerie",
"continueWatching": "Continua a guardare",
"recentlyAdded": "Aggiunti di recente",
"play": "Riproduci",
"resume": "Riprendi",
"playEpisode": "Riproduci S${season}, E${episode}",
"resumeEpisode": "Riprendi S${season}, E${episode}",
"pause": "Pausa",
"overview": "Panoramica",
"episodeCount": "${count} episodi",
"watchedProgress": "${watched}/${total} guardati",
"movie": "Film",
"tvShow": "Serie TV",
"minutesLeft": "${minutes} minuti rimanenti"
},
"errors": {
"searchFailed": "Ricerca fallita: ${error}",
"connectionTimeout": "Timeout connessione durante caricamento di ${context}",
"connectionFailed": "Impossibile connettersi al server Plex.",
"failedToLoad": "Impossibile caricare ${context}: ${error}",
"noClientAvailable": "Nessun client disponibile",
"authenticationFailed": "Autenticazione fallita: ${error}",
"couldNotLaunchUrl": "Impossibile avviare URL di autenticazione",
"pleaseEnterToken": "Inserisci token",
"invalidToken": "Token non valido",
"failedToVerifyToken": "Verifica token fallita: ${error}",
"failedToSwitchProfile": "Impossibile passare a ${displayName}",
"connectionFailedGeneric": "Connessione fallita"
},
"libraries": {
"title": "Librerie",
"scanLibraryFiles": "Scansiona file libreria",
"scanLibrary": "Scansiona libreria",
"analyze": "Analizza",
"analyzeLibrary": "Analizza libreria",
"refreshMetadata": "Aggiorna metadati",
"emptyTrash": "Svuota cestino",
"emptyingTrash": "Svuotamento cestino per \"${title}\"...",
"trashEmptied": "Cestino svuotato per \"${title}\"",
"failedToEmptyTrash": "Impossibile svuotare cestino: ${error}",
"analyzing": "Analisi \"${title}\"...",
"analysisStarted": "Analisi iniziata per \"${title}\"",
"failedToAnalyze": "Impossibile analizzare libreria: ${error}",
"noLibrariesFound": "Nessuna libreria trovata",
"thisLibraryIsEmpty": "Questa libreria è vuota",
"all": "Tutto",
"clearAll": "Cancella tutto",
"scanLibraryConfirm": "Sei sicuro di voler scansionare \"${title}\"?",
"analyzeLibraryConfirm": "Sei sicuro di voler analizzare \"${title}\"?",
"refreshMetadataConfirm": "Sei sicuro di voler aggiornare i metadati per \"${title}\"?",
"emptyTrashConfirm": "Sei sicuro di voler svuotare il cestino per \"${title}\"?",
"manageLibraries": "Gestisci librerie",
"sort": "Ordina",
"sortBy": "Ordina per",
"filters": "Filtri",
"loadingLibraryWithCount": "Caricamento librerie... (${count} oggetti caricati)",
"confirmActionMessage": "Sei sicuro di voler eseguire questa azione?",
"showLibrary": "Mostra libreria",
"hideLibrary": "Nascondi libreria",
"libraryOptions": "Opzioni libreria"
},
"about": {
"title": "Informazioni",
"openSourceLicenses": "Licenze Open Source",
"versionLabel": "Versione ${version}",
"appDescription": "Un bellissimo client Plex per Flutter",
"viewLicensesDescription": "Visualizza le licenze delle librerie di terze parti"
},
"serverSelection": {
"connectingToServer": "Connessione al server...",
"serverDebugCopied": "Dati di debug del server copiati negli appunti",
"copyDebugData": "Copia dati di debug",
"noServersFound": "Nessun server trovato",
"malformedServerData": "Trovato ${count} server con dati difettosi. Nessun server valido disponibile.",
"incompleteServerInfo": "Alcuni server presentano informazioni incomplete e sono stati ignorati. Controlla il tuo account Plex.tv.",
"incompleteConnectionInfo": "Le informazioni di connessione al server sono incomplete. Riprova.",
"malformedServerInfo": "Le informazioni sul server sono errate: ${message}",
"networkConnectionFailed": "Connessione di rete non riuscita. Controlla la tua connessione Internet e riprova.",
"authenticationFailed": "Autenticazione fallita. Effettua nuovamente l'accesso.",
"plexServiceUnavailable": "Servizio Plex non disponibile. Riprova più tardi.",
"failedToLoadServers": "Impossibile caricare i server: ${error}"
},
"hubDetail": {
"title": "Titolo",
"releaseYear": "Anno rilascio",
"dateAdded": "Data aggiunta",
"rating": "Valutazione",
"noItemsFound": "Nessun elemento trovato"
},
"logs": {
"title": "Log",
"clearLogs": "Cancella log",
"copyLogs": "Copia log",
"exportLogs": "Esporta log",
"noLogsToShow": "Nessun log da mostrare",
"error": "Errore:",
"stackTrace": "Traccia dello stack:"
},
"licenses": {
"relatedPackages": "Pacchetti correlati",
"license": "Licenza",
"licenseNumber": "Licenza ${number}",
"licensesCount": "${count} licenze"
},
"navigation": {
"home": "Home",
"search": "Cerca",
"libraries": "Librerie",
"settings": "Impostazioni"
}
}
+365
View File
@@ -0,0 +1,365 @@
{
"app": {
"title": "Plezy",
"loading": "Laddar..."
},
"auth": {
"signInWithPlex": "Logga in med Plex",
"showQRCode": "Visa QR-kod",
"cancel": "Avbryt",
"authenticate": "Autentisera",
"retry": "Försök igen",
"debugEnterToken": "Debug: Ange Plex-token",
"plexTokenLabel": "Plex-autentiseringstoken",
"plexTokenHint": "Ange din Plex.tv-token",
"authenticationTimeout": "Autentisering tog för lång tid. Försök igen.",
"scanQRCodeInstruction": "Skanna denna QR-kod med en enhet inloggad på Plex för att autentisera.",
"waitingForAuth": "Väntar på autentisering...\nVänligen slutför inloggning i din webbläsare."
},
"common": {
"cancel": "Avbryt",
"save": "Spara",
"close": "Stäng",
"clear": "Rensa",
"reset": "Återställ",
"later": "Senare",
"submit": "Skicka",
"confirm": "Bekräfta",
"retry": "Försök igen",
"playNow": "Spela nu",
"logout": "Logga ut",
"online": "Online",
"offline": "Offline",
"owned": "Egen",
"shared": "Delad",
"current": "NUVARANDE",
"unknown": "Okänd",
"refresh": "Uppdatera",
"yes": "Ja",
"no": "Nej",
"server": "Server"
},
"screens": {
"licenses": "Licenser",
"selectServer": "Välj server",
"switchProfile": "Byt profil",
"subtitleStyling": "Undertext-styling",
"search": "Sök",
"logs": "Loggar"
},
"update": {
"available": "Uppdatering tillgänglig",
"versionAvailable": "Version ${version} är tillgänglig",
"currentVersion": "Nuvarande: ${version}",
"skipVersion": "Hoppa över denna version",
"viewRelease": "Visa release",
"latestVersion": "Du har den senaste versionen",
"checkFailed": "Misslyckades att kontrollera uppdateringar"
},
"settings": {
"title": "Inställningar",
"language": "Språk",
"theme": "Tema",
"appearance": "Utseende",
"videoPlayback": "Videouppspelning",
"shufflePlay": "Blanda uppspelning",
"advanced": "Avancerat",
"useSeasonPostersDescription": "Visa säsongsaffisch istället för serieaffisch för avsnitt",
"showHeroSectionDescription": "Visa utvalda innehållskarusell på startsidan",
"secondsLabel": "Sekunder",
"minutesLabel": "Minuter",
"secondsShort": "s",
"minutesShort": "m",
"durationHint": "Ange tid (${min}-${max})",
"systemTheme": "System",
"systemThemeDescription": "Följ systeminställningar",
"lightTheme": "Ljust",
"darkTheme": "Mörkt",
"libraryDensity": "Biblioteksdensitet",
"compact": "Kompakt",
"compactDescription": "Mindre kort, fler objekt synliga",
"normal": "Normal",
"normalDescription": "Standardstorlek",
"comfortable": "Bekväm",
"comfortableDescription": "Större kort, färre objekt synliga",
"viewMode": "Visningsläge",
"gridView": "Rutnät",
"gridViewDescription": "Visa objekt i rutnätslayout",
"listView": "Lista",
"listViewDescription": "Visa objekt i listlayout",
"useSeasonPosters": "Använd säsongsaffischer",
"showHeroSection": "Visa hjältesektion",
"hardwareDecoding": "Hårdvaruavkodning",
"hardwareDecodingDescription": "Använd hårdvaruacceleration när tillgängligt",
"bufferSize": "Bufferstorlek",
"bufferSizeMB": "${size}MB",
"subtitleStyling": "Undertext-styling",
"subtitleStylingDescription": "Anpassa undertextutseende",
"smallSkipDuration": "Kort hoppvaraktighet",
"largeSkipDuration": "Lång hoppvaraktighet",
"secondsUnit": "${seconds} sekunder",
"defaultSleepTimer": "Standard sovtimer",
"minutesUnit": "${minutes} minuter",
"unwatchedOnly": "Endast osedda",
"unwatchedOnlyDescription": "Inkludera endast osedda avsnitt i blandningskön",
"shuffleOrderNavigation": "Blandningsordning-navigation",
"shuffleOrderNavigationDescription": "Nästa/föregående knappar följer blandad ordning",
"loopShuffleQueue": "Loopa blandningskö",
"loopShuffleQueueDescription": "Starta om kö när slutet nås",
"videoPlayerControls": "Videospelar-kontroller",
"keyboardShortcuts": "Tangentbordsgenvägar",
"keyboardShortcutsDescription": "Anpassa tangentbordsgenvägar",
"debugLogging": "Felsökningsloggning",
"debugLoggingDescription": "Aktivera detaljerad loggning för felsökning",
"viewLogs": "Visa loggar",
"viewLogsDescription": "Visa applikationsloggar",
"clearCache": "Rensa cache",
"clearCacheDescription": "Detta rensar alla cachade bilder och data. Appen kan ta längre tid att ladda innehåll efter cache-rensning.",
"clearCacheSuccess": "Cache rensad framgångsrikt",
"resetSettings": "Återställ inställningar",
"resetSettingsDescription": "Detta återställer alla inställningar till standardvärden. Denna åtgärd kan inte ångras.",
"resetSettingsSuccess": "Inställningar återställda framgångsrikt",
"shortcutsReset": "Genvägar återställda till standard",
"about": "Om",
"aboutDescription": "Appinformation och licenser",
"updates": "Uppdateringar",
"updateAvailable": "Uppdatering tillgänglig",
"checkForUpdates": "Kontrollera uppdateringar",
"validationErrorEnterNumber": "Vänligen ange ett giltigt nummer",
"validationErrorDuration": "Tiden måste vara mellan ${min} och ${max} ${unit}",
"shortcutAlreadyAssigned": "Genväg redan tilldelad ${action}",
"shortcutUpdated": "Genväg uppdaterad för ${action}"
},
"search": {
"hint": "Sök filmer, serier, musik...",
"tryDifferentTerm": "Prova en annan sökterm"
},
"hotkeys": {
"setShortcutFor": "Sätt genväg för ${actionName}",
"clearShortcut": "Rensa genväg"
},
"pinEntry": {
"enterPin": "Ange PIN",
"showPin": "Visa PIN",
"hidePin": "Dölj PIN"
},
"fileInfo": {
"title": "Filinformation",
"video": "Video",
"audio": "Ljud",
"file": "Fil",
"advanced": "Avancerat",
"codec": "Kodek",
"resolution": "Upplösning",
"bitrate": "Bithastighet",
"frameRate": "Bildfrekvens",
"aspectRatio": "Bildförhållande",
"profile": "Profil",
"bitDepth": "Bitdjup",
"colorSpace": "Färgrymd",
"colorRange": "Färgområde",
"colorPrimaries": "Färggrunder",
"chromaSubsampling": "Kroma-undersampling",
"channels": "Kanaler",
"path": "Sökväg",
"size": "Storlek",
"container": "Container",
"duration": "Varaktighet",
"optimizedForStreaming": "Optimerad för streaming",
"has64bitOffsets": "64-bit offset"
},
"mediaMenu": {
"markAsWatched": "Markera som sedd",
"markAsUnwatched": "Markera som osedd",
"goToSeries": "Gå till serie",
"goToSeason": "Gå till säsong",
"shufflePlay": "Blanda uppspelning",
"fileInfo": "Filinformation"
},
"tooltips": {
"shufflePlay": "Blanda uppspelning",
"markAsWatched": "Markera som sedd",
"markAsUnwatched": "Markera som osedd"
},
"videoControls": {
"audioLabel": "Ljud",
"subtitlesLabel": "Undertexter",
"resetToZero": "Återställ till 0ms",
"addTime": "+${amount}${unit}",
"minusTime": "-${amount}${unit}",
"playsLater": "${label} spelas senare",
"playsEarlier": "${label} spelas tidigare",
"noOffset": "Ingen offset",
"letterbox": "Letterbox",
"fillScreen": "Fyll skärm",
"stretch": "Sträck",
"lockRotation": "Lås rotation",
"unlockRotation": "Lås upp rotation"
},
"userStatus": {
"admin": "Admin",
"restricted": "Begränsad",
"protected": "Skyddad"
},
"messages": {
"markedAsWatched": "Markerad som sedd",
"markedAsUnwatched": "Markerad som osedd",
"errorLoading": "Fel: ${error}",
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
"errorLoadingSeason": "Fel vid laddning av säsong",
"musicNotSupported": "Musikuppspelning stöds inte ännu",
"logsCleared": "Loggar rensade",
"logsCopied": "Loggar kopierade till urklipp",
"noLogsAvailable": "Inga loggar tillgängliga",
"libraryScanning": "Skannar \"${title}\"...",
"libraryScanStarted": "Biblioteksskanning startad för \"${title}\"",
"libraryScanFailed": "Misslyckades att skanna bibliotek: ${error}",
"metadataRefreshing": "Uppdaterar metadata för \"${title}\"...",
"metadataRefreshStarted": "Metadata-uppdatering startad för \"${title}\"",
"metadataRefreshFailed": "Misslyckades att uppdatera metadata: ${error}",
"noPlexToken": "Ingen Plex-token hittad. Vänligen logga in igen.",
"logoutConfirm": "Är du säker på att du vill logga ut?",
"noSeasonsFound": "Inga säsonger hittades",
"noEpisodesFound": "Inga avsnitt hittades i första säsongen",
"noEpisodesFoundGeneral": "Inga avsnitt hittades",
"noResultsFound": "Inga resultat hittades",
"sleepTimerSet": "Sovtimer inställd för ${label}",
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}"
},
"profile": {
"noUsersAvailable": "Inga användare tillgängliga"
},
"subtitlingStyling": {
"stylingOptions": "Stilalternativ",
"fontSize": "Teckenstorlek",
"textColor": "Textfärg",
"borderSize": "Kantstorlek",
"borderColor": "Kantfärg",
"backgroundOpacity": "Bakgrundsopacitet",
"backgroundColor": "Bakgrundsfärg"
},
"dialog": {
"confirmAction": "Bekräfta åtgärd",
"areYouSure": "Är du säker på att du vill utföra denna åtgärd?",
"cancel": "Avbryt",
"playNow": "Spela nu"
},
"discover": {
"title": "Upptäck",
"switchProfile": "Byt profil",
"switchServer": "Byt server",
"logout": "Logga ut",
"noContentAvailable": "Inget innehåll tillgängligt",
"addMediaToLibraries": "Lägg till media till dina bibliotek",
"continueWatching": "Fortsätt titta",
"recentlyAdded": "Nyligen tillagda",
"play": "Spela",
"resume": "Återuppta",
"playEpisode": "Spela S${season}, E${episode}",
"resumeEpisode": "Återuppta S${season}, E${episode}",
"pause": "Pausa",
"overview": "Översikt",
"episodeCount": "${count} avsnitt",
"watchedProgress": "${watched}/${total} sedda",
"movie": "Film",
"tvShow": "TV-serie",
"minutesLeft": "${minutes} min kvar"
},
"errors": {
"searchFailed": "Sökning misslyckades: ${error}",
"connectionTimeout": "Anslutnings-timeout vid laddning ${context}",
"connectionFailed": "Kan inte ansluta till Plex-server",
"failedToLoad": "Misslyckades att ladda ${context}: ${error}",
"noClientAvailable": "Ingen klient tillgänglig",
"authenticationFailed": "Autentisering misslyckades: ${error}",
"couldNotLaunchUrl": "Kunde inte öppna autentiserings-URL",
"pleaseEnterToken": "Vänligen ange en token",
"invalidToken": "Ogiltig token",
"failedToVerifyToken": "Misslyckades att verifiera token: ${error}",
"failedToSwitchProfile": "Misslyckades att byta till ${displayName}",
"connectionFailedGeneric": "Anslutning misslyckades"
},
"libraries": {
"title": "Bibliotek",
"scanLibraryFiles": "Skanna biblioteksfiler",
"scanLibrary": "Skanna bibliotek",
"analyze": "Analysera",
"analyzeLibrary": "Analysera bibliotek",
"refreshMetadata": "Uppdatera metadata",
"emptyTrash": "Töm papperskorg",
"emptyingTrash": "Tömmer papperskorg för \"${title}\"...",
"trashEmptied": "Papperskorg tömd för \"${title}\"",
"failedToEmptyTrash": "Misslyckades att tömma papperskorg: ${error}",
"analyzing": "Analyserar \"${title}\"...",
"analysisStarted": "Analys startad för \"${title}\"",
"failedToAnalyze": "Misslyckades att analysera bibliotek: ${error}",
"noLibrariesFound": "Inga bibliotek hittades",
"thisLibraryIsEmpty": "Detta bibliotek är tomt",
"all": "Alla",
"clearAll": "Rensa alla",
"scanLibraryConfirm": "Är du säker på att du vill skanna \"${title}\"?",
"analyzeLibraryConfirm": "Är du säker på att du vill analysera \"${title}\"?",
"refreshMetadataConfirm": "Är du säker på att du vill uppdatera metadata för \"${title}\"?",
"emptyTrashConfirm": "Är du säker på att du vill tömma papperskorgen för \"${title}\"?",
"manageLibraries": "Hantera bibliotek",
"sort": "Sortera",
"sortBy": "Sortera efter",
"filters": "Filter",
"loadingLibraryWithCount": "Laddar bibliotek... (${count} objekt laddade)",
"confirmActionMessage": "Är du säker på att du vill utföra denna åtgärd?",
"showLibrary": "Visa bibliotek",
"hideLibrary": "Dölj bibliotek",
"libraryOptions": "Biblioteksalternativ"
},
"about": {
"title": "Om",
"openSourceLicenses": "Öppen källkod-licenser",
"versionLabel": "Version ${version}",
"appDescription": "En vacker Plex-klient för Flutter",
"viewLicensesDescription": "Visa licenser för tredjepartsbibliotek"
},
"serverSelection": {
"connectingToServer": "Ansluter till server...",
"serverDebugCopied": "Server-felsökningsdata kopierad till urklipp",
"copyDebugData": "Kopiera felsökningsdata",
"noServersFound": "Inga servrar hittades",
"malformedServerData": "Hittade ${count} server(ar) med felformaterad data. Inga giltiga servrar tillgängliga.",
"incompleteServerInfo": "Vissa servrar har ofullständig information och hoppades över. Vänligen kontrollera ditt Plex.tv-konto.",
"incompleteConnectionInfo": "Server-anslutningsinformation är ofullständig. Försök igen.",
"malformedServerInfo": "Serverinformation är felformaterad: ${message}",
"networkConnectionFailed": "Nätverksanslutning misslyckades. Kontrollera din internetanslutning och försök igen.",
"authenticationFailed": "Autentisering misslyckades. Logga in igen.",
"plexServiceUnavailable": "Plex-tjänst otillgänglig. Försök igen senare.",
"failedToLoadServers": "Misslyckades att ladda servrar: ${error}"
},
"hubDetail": {
"title": "Titel",
"releaseYear": "Utgivningsår",
"dateAdded": "Datum tillagd",
"rating": "Betyg",
"noItemsFound": "Inga objekt hittades"
},
"logs": {
"title": "Loggar",
"clearLogs": "Rensa loggar",
"copyLogs": "Kopiera loggar",
"exportLogs": "Exportera loggar",
"noLogsToShow": "Inga loggar att visa",
"error": "Fel:",
"stackTrace": "Stack trace:"
},
"licenses": {
"relatedPackages": "Relaterade paket",
"license": "Licens",
"licenseNumber": "Licens ${number}",
"licensesCount": "${count} licenser"
},
"navigation": {
"home": "Hem",
"search": "Sök",
"libraries": "Bibliotek",
"settings": "Inställningar"
}
}
+28 -19
View File
@@ -23,10 +23,18 @@ import 'utils/language_codes.dart';
import 'utils/app_logger.dart';
import 'utils/provider_extensions.dart';
import 'utils/orientation_helper.dart';
import 'i18n/strings.g.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize settings first to get saved locale
final settings = await SettingsService.getInstance();
final savedLocale = settings.getAppLocale();
// Initialize localization with saved locale
LocaleSettings.setLocale(savedLocale);
// Configure image cache for large libraries
PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB
PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images
@@ -50,7 +58,6 @@ void main() async {
await LanguageCodes.initialize();
// Initialize logger level based on debug setting
final settings = await SettingsService.getInstance();
final debugEnabled = settings.getEnableDebugLogging();
setLoggerLevel(debugEnabled);
@@ -83,14 +90,16 @@ class MainApp extends StatelessWidget {
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
title: 'Plezy',
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const OrientationAwareSetup(),
return TranslationProvider(
child: MaterialApp(
title: t.app.title,
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme,
darkTheme: themeProvider.darkTheme,
themeMode: themeProvider.materialThemeMode,
navigatorObservers: [routeObserver],
home: const OrientationAwareSetup(),
),
);
},
),
@@ -158,18 +167,18 @@ class _SetupScreenState extends State<SetupScreen> {
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Update Available'),
title: Text(t.update.available),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Version ${updateInfo['latestVersion']} is available',
t.update.versionAvailable(version: updateInfo['latestVersion']),
style: Theme.of(dialogContext).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Current: ${updateInfo['currentVersion']}',
t.update.currentVersion(version: updateInfo['currentVersion']),
style: Theme.of(dialogContext).textTheme.bodySmall,
),
],
@@ -179,7 +188,7 @@ class _SetupScreenState extends State<SetupScreen> {
onPressed: () {
Navigator.pop(dialogContext);
},
child: const Text('Later'),
child: Text(t.common.later),
),
TextButton(
onPressed: () async {
@@ -188,7 +197,7 @@ class _SetupScreenState extends State<SetupScreen> {
Navigator.pop(dialogContext);
}
},
child: const Text('Skip This Version'),
child: Text(t.update.skipVersion),
),
FilledButton(
onPressed: () async {
@@ -200,7 +209,7 @@ class _SetupScreenState extends State<SetupScreen> {
Navigator.pop(dialogContext);
}
},
child: const Text('View Release'),
child: Text(t.update.viewRelease),
),
],
);
@@ -295,14 +304,14 @@ class _SetupScreenState extends State<SetupScreen> {
@override
Widget build(BuildContext context) {
return const Scaffold(
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading...'),
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(t.app.loading),
],
),
),
+2
View File
@@ -13,6 +13,7 @@ class PlexLibrary {
final String? uuid;
final int? updatedAt;
final int? createdAt;
final int? hidden;
PlexLibrary({
required this.key,
@@ -24,6 +25,7 @@ class PlexLibrary {
this.uuid,
this.updatedAt,
this.createdAt,
this.hidden,
});
factory PlexLibrary.fromJson(Map<String, dynamic> json) =>
+2
View File
@@ -16,6 +16,7 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
uuid: json['uuid'] as String?,
updatedAt: (json['updatedAt'] as num?)?.toInt(),
createdAt: (json['createdAt'] as num?)?.toInt(),
hidden: (json['hidden'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
@@ -29,4 +30,5 @@ Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
+30
View File
@@ -0,0 +1,30 @@
import 'plex_media_info.dart';
import 'plex_media_version.dart';
/// Consolidated data model containing all information needed for video playback.
/// This model combines data from multiple Plex API endpoints to reduce redundant requests.
class PlexVideoPlaybackData {
/// Direct video URL for playback
final String? videoUrl;
/// Media information including audio/subtitle tracks and chapters
final PlexMediaInfo? mediaInfo;
/// Available media versions/qualities for this content
final List<PlexMediaVersion> availableVersions;
PlexVideoPlaybackData({
required this.videoUrl,
required this.mediaInfo,
required this.availableVersions,
});
/// Returns true if this playback data has a valid video URL
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
/// Returns true if media info is available
bool get hasMediaInfo => mediaInfo != null;
/// Returns true if there are multiple media versions available
bool get hasMultipleVersions => availableVersions.length > 1;
}
+8 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
import 'licenses_screen.dart';
class AboutScreen extends StatefulWidget {
@@ -23,7 +24,7 @@ class _AboutScreenState extends State<AboutScreen> {
Future<void> _loadPackageInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appName = 'Plezy';
_appName = t.app.title;
_appVersion = packageInfo.version;
});
}
@@ -36,7 +37,7 @@ class _AboutScreenState extends State<AboutScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: const Text('About'), pinned: true),
CustomAppBar(title: Text(t.about.title), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -55,14 +56,14 @@ class _AboutScreenState extends State<AboutScreen> {
),
const SizedBox(height: 8),
Text(
'Version $appVersion',
t.about.versionLabel(version: appVersion),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
const SizedBox(height: 24),
Text(
'A beautiful Plex client for Flutter',
t.about.appDescription,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
@@ -76,9 +77,9 @@ class _AboutScreenState extends State<AboutScreen> {
Card(
child: ListTile(
leading: const Icon(Icons.description),
title: const Text('Open Source Licenses'),
subtitle: const Text(
'View licenses of third-party libraries',
title: Text(t.about.openSourceLicenses),
subtitle: Text(
t.about.viewLicensesDescription,
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
+20 -19
View File
@@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../i18n/strings.g.dart';
import 'server_selection_screen.dart';
class AuthScreen extends StatefulWidget {
@@ -63,7 +64,7 @@ class _AuthScreenState extends State<AuthScreen> {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
} else {
throw Exception('Could not launch auth URL');
throw Exception(t.errors.couldNotLaunchUrl);
}
}
@@ -81,7 +82,7 @@ class _AuthScreenState extends State<AuthScreen> {
if (token == null) {
setState(() {
_isAuthenticating = false;
_errorMessage = 'Authentication timed out. Please try again.';
_errorMessage = t.auth.authenticationTimeout;
});
return;
}
@@ -119,7 +120,7 @@ class _AuthScreenState extends State<AuthScreen> {
} catch (e) {
setState(() {
_isAuthenticating = false;
_errorMessage = 'Authentication failed: $e';
_errorMessage = t.errors.authenticationFailed(error: e);
});
}
}
@@ -149,15 +150,15 @@ class _AuthScreenState extends State<AuthScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Debug: Enter Plex Token'),
title: Text(t.auth.debugEnterToken),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
hintText: 'Enter your Plex.tv token',
labelText: t.auth.plexTokenLabel,
hintText: t.auth.plexTokenHint,
errorText: errorMessage,
border: const OutlineInputBorder(),
),
@@ -169,14 +170,14 @@ class _AuthScreenState extends State<AuthScreen> {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(t.auth.cancel),
),
ElevatedButton(
onPressed: () async {
final token = tokenController.text.trim();
if (token.isEmpty) {
setDialogState(() {
errorMessage = 'Please enter a token';
errorMessage = t.errors.pleaseEnterToken;
});
return;
}
@@ -187,7 +188,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isValid = await _authService.verifyToken(token);
if (!isValid) {
setDialogState(() {
errorMessage = 'Invalid token';
errorMessage = t.errors.invalidToken;
});
return;
}
@@ -210,11 +211,11 @@ class _AuthScreenState extends State<AuthScreen> {
}
} catch (e) {
setDialogState(() {
errorMessage = 'Failed to verify token: $e';
errorMessage = t.errors.failedToVerifyToken(error: e);
});
}
},
child: const Text('Authenticate'),
child: Text(t.auth.authenticate),
),
],
);
@@ -238,7 +239,7 @@ class _AuthScreenState extends State<AuthScreen> {
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
'Plezy',
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -250,8 +251,8 @@ class _AuthScreenState extends State<AuthScreen> {
const SizedBox(height: 16),
Text(
_useQrFlow
? 'Scan this QR code with a device logged into Plex to authenticate.'
: 'Waiting for authentication...\nPlease complete sign-in in your browser.',
? t.auth.scanQRCodeInstruction
: t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
@@ -281,7 +282,7 @@ class _AuthScreenState extends State<AuthScreen> {
horizontal: 24,
),
),
child: const Text('Retry'),
child: Text(t.auth.retry),
),
] else ...[ // add QR button here
ElevatedButton(
@@ -289,7 +290,7 @@ class _AuthScreenState extends State<AuthScreen> {
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Sign in with Plex'),
child: Text(t.auth.signInWithPlex),
),
const SizedBox(height: 12),
OutlinedButton(
@@ -302,7 +303,7 @@ class _AuthScreenState extends State<AuthScreen> {
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Show QR Code'),
child: Text(t.auth.showQRCode),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
@@ -316,8 +317,8 @@ class _AuthScreenState extends State<AuthScreen> {
).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: const Text(
'Debug: Enter Token',
child: Text(
t.auth.debugEnterToken,
style: TextStyle(fontSize: 12),
),
),
+70 -83
View File
@@ -18,6 +18,7 @@ import 'hub_detail_screen.dart';
import '../providers/user_profile_provider.dart';
import '../providers/settings_provider.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
import '../mixins/item_updatable.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
@@ -42,7 +43,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
PlexClient get client => context.clientSafe;
List<PlexMetadata> _onDeck = [];
List<PlexMetadata> _recentlyAdded = [];
List<PlexHub> _hubs = [];
bool _isLoading = true;
String? _errorMessage;
@@ -167,7 +167,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
});
try {
appLogger.d('Fetching onDeck and recentlyAdded from Plex');
appLogger.d('Fetching onDeck and hubs from Plex');
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
@@ -175,48 +175,45 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
final onDeck = await client.getOnDeck();
final recentlyAdded = await client.getRecentlyAdded(limit: 20);
// Load hubs from all libraries
final libraries = await client.getLibraries();
final allHubs = <PlexHub>[];
for (final library in libraries) {
// Only fetch hubs for movie and show libraries
if (library.type == 'movie' || library.type == 'show') {
try {
final libraryHubs = await client.getLibraryHubs(
library.key,
limit: 12,
);
// Filter out duplicate hubs that we already fetch separately
final filteredHubs = libraryHubs.where((hub) {
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
final title = hub.title.toLowerCase();
// Skip "Continue Watching", "On Deck", and "Recently Added" hubs
return !hubId.contains('ondeck') &&
!hubId.contains('continue') &&
!hubId.contains('recentlyadded') &&
!title.contains('continue watching') &&
!title.contains('on deck') &&
!title.contains('recently added');
}).toList();
allHubs.addAll(filteredHubs);
} catch (e) {
appLogger.w(
'Failed to load hubs for library ${library.title}',
error: e,
);
}
// Skip libraries that are not movie/show or are hidden
if (library.type != 'movie' && library.type != 'show') continue;
if (library.hidden != 0) continue;
try {
final libraryHubs = await client.getLibraryHubs(
library.key,
limit: 12,
);
// Filter out duplicate hubs that we already fetch separately
final filteredHubs = libraryHubs.where((hub) {
final hubId = hub.hubIdentifier?.toLowerCase() ?? '';
final title = hub.title.toLowerCase();
// Skip "Continue Watching" and "On Deck" hubs (we handle these separately)
return !hubId.contains('ondeck') &&
!hubId.contains('continue') &&
!title.contains('continue watching') &&
!title.contains('on deck');
}).toList();
allHubs.addAll(filteredHubs);
} catch (e) {
appLogger.w(
'Failed to load hubs for library ${library.title}',
error: e,
);
}
}
appLogger.d(
'Received ${onDeck.length} on deck items, ${recentlyAdded.length} recently added items, and ${allHubs.length} hubs',
'Received ${onDeck.length} on deck items and ${allHubs.length} hubs',
);
setState(() {
_onDeck = onDeck;
_recentlyAdded = recentlyAdded;
_hubs = allHubs;
_isLoading = false;
@@ -273,7 +270,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
}
// Public method to refresh content
// Public method to refresh content (for normal navigation)
@override
void refresh() {
appLogger.d('DiscoverScreen.refresh() called');
@@ -281,6 +278,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_refreshContinueWatching();
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d('DiscoverScreen.fullRefresh() called - reloading all content');
// Reload all content including On Deck and content hubs
_loadContent();
}
/// Get icon for hub based on its title
IconData _getHubIcon(String title) {
final lowerTitle = title.toLowerCase();
@@ -391,12 +395,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_onDeck[onDeckIndex] = updatedMetadata;
}
// Check and update in _recentlyAdded list
final recentlyAddedIndex = _recentlyAdded.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (recentlyAddedIndex != -1) {
_recentlyAdded[recentlyAddedIndex] = updatedMetadata;
// Check and update in hub items
for (final hub in _hubs) {
final itemIndex = hub.items.indexWhere(
(item) => item.ratingKey == ratingKey,
);
if (itemIndex != -1) {
hub.items[itemIndex] = updatedMetadata;
}
}
}
@@ -407,8 +413,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (plexToken == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No Plex token found. Please login again.'),
SnackBar(
content: Text(t.messages.noPlexToken),
),
);
}
@@ -433,7 +439,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to initialize server selection: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
@@ -443,16 +449,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
title: Text(t.common.logout),
content: Text(t.messages.logoutConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Logout'),
child: Text(t.common.logout),
),
],
),
@@ -497,7 +503,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
controller: _scrollController,
slivers: [
DesktopSliverAppBar(
title: const Text('Discover'),
title: Text(t.discover.title),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
@@ -531,33 +537,33 @@ class _DiscoverScreenState extends State<DiscoverScreen>
itemBuilder: (context) => [
// Only show Switch Profile if multiple users available
if (userProvider.hasMultipleUsers)
const PopupMenuItem(
PopupMenuItem(
value: 'switch_profile',
child: Row(
children: [
Icon(Icons.people),
SizedBox(width: 8),
Text('Switch Profile'),
Text(t.discover.switchProfile),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'switch_server',
child: Row(
children: [
Icon(Icons.swap_horiz),
SizedBox(width: 8),
Text('Switch Server'),
Text(t.discover.switchServer),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 8),
Text('Logout'),
Text(t.discover.logout),
],
),
),
@@ -587,7 +593,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadContent,
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
@@ -614,7 +620,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
const Icon(Icons.play_circle_outline),
const SizedBox(width: 8),
Text(
'Continue Watching',
t.discover.continueWatching,
style: Theme.of(context).textTheme.titleLarge,
),
],
@@ -624,25 +630,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_buildHorizontalList(_onDeck, isLarge: false),
],
// Recently Added
if (_recentlyAdded.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
const Icon(Icons.fiber_new),
const SizedBox(width: 8),
Text(
'Recently Added',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
),
_buildHorizontalList(_recentlyAdded, isLarge: false),
],
// Recommendation Hubs (Trending, Top in Genre, etc.)
for (final hub in _hubs) ...[
@@ -683,8 +670,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_buildHorizontalList(hub.items, isLarge: false),
],
if (_onDeck.isEmpty && _recentlyAdded.isEmpty && _hubs.isEmpty)
const SliverFillRemaining(
if (_onDeck.isEmpty && _hubs.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -695,10 +682,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
color: Colors.grey,
),
SizedBox(height: 16),
Text('No content available'),
Text(t.discover.noContentAvailable),
SizedBox(height: 8),
Text(
'Add some media to your libraries',
t.discover.addMediaToLibraries,
style: TextStyle(color: Colors.grey),
),
],
@@ -758,7 +745,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
color: Colors.white,
size: 18,
semanticLabel:
'${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll',
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
),
),
// Spacer to separate indicators from button
@@ -848,8 +835,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Determine content type label for chip
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
? 'Movie'
: 'TV Show';
? t.discover.movie
: t.discover.tvShow;
return Semantics(
label: "media-hero-${heroItem.ratingKey}",
@@ -1224,7 +1211,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
const SizedBox(width: 8),
Text(
'$minutesLeft min left',
t.discover.minutesLeft(minutes: minutesLeft),
style: const TextStyle(
color: Colors.black,
fontSize: 14,
@@ -1232,8 +1219,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
),
),
] else
const Text(
'Play',
Text(
t.discover.play,
style: TextStyle(
color: Colors.black,
fontSize: 14,
+10 -9
View File
@@ -12,6 +12,7 @@ import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/sort_bottom_sheet.dart';
import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart';
/// Screen to display full content of a recommendation hub
class HubDetailScreen extends StatefulWidget {
@@ -99,23 +100,23 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
List<PlexSort> _getDefaultSortOptions() {
return [
PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'),
PlexSort(key: 'titleSort', title: t.hubDetail.title, defaultDirection: 'asc'),
PlexSort(
key: 'year',
descKey: 'year:desc',
title: 'Release Year',
title: t.hubDetail.releaseYear,
defaultDirection: 'desc',
),
PlexSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: 'Date Added',
title: t.hubDetail.dateAdded,
defaultDirection: 'desc',
),
PlexSort(
key: 'rating',
descKey: 'rating:desc',
title: 'Rating',
title: t.hubDetail.rating,
defaultDirection: 'desc',
),
];
@@ -216,7 +217,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
} catch (e) {
appLogger.e('Failed to load hub content', error: e);
setState(() {
_errorMessage = 'Failed to load content: $e';
_errorMessage = t.messages.errorLoading(error: e.toString());
_isLoading = false;
});
}
@@ -248,7 +249,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
pinned: true,
actions: [
IconButton(
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
onPressed: _showSortBottomSheet,
),
],
@@ -269,7 +270,7 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadMoreItems,
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
@@ -280,8 +281,8 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
child: Center(child: CircularProgressIndicator()),
)
else if (_filteredItems.isEmpty)
const SliverFillRemaining(
child: Center(child: Text('No items found')),
SliverFillRemaining(
child: Center(child: Text(t.hubDetail.noItemsFound)),
)
else
SliverPadding(
+86 -75
View File
@@ -20,6 +20,7 @@ import '../services/settings_service.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
class LibrariesScreen extends StatefulWidget {
const LibrariesScreen({super.key});
@@ -66,18 +67,18 @@ class _LibrariesScreenState extends State<LibrariesScreen>
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.receiveTimeout:
return 'Connection timeout while loading $context';
return t.errors.connectionTimeout(context: context);
case DioExceptionType.connectionError:
return 'Unable to connect to Plex server';
return t.errors.connectionFailed;
default:
appLogger.e('Error loading $context', error: error);
return 'Failed to load $context: ${error.message}';
return t.errors.failedToLoad(context: context, error: error.message ?? 'Unknown error');
}
}
// Generic error
appLogger.e('Unexpected error in $context', error: error);
return 'Failed to load $context: $error';
return t.errors.failedToLoad(context: context, error: error.toString());
}
Future<void> _loadLibraries() async {
@@ -99,7 +100,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
try {
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
final storage = await StorageService.getInstance();
@@ -233,7 +234,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final client = clientProvider.client;
if (client == null) {
setState(() {
_errorMessage = 'No client available';
_errorMessage = t.errors.noClientAvailable;
_isLoadingItems = false;
});
return;
@@ -364,7 +365,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
final filters = await client.getLibraryFilters(libraryKey);
@@ -387,7 +388,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
final sortOptions = await client.getLibrarySorts(libraryKey);
@@ -449,7 +450,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
);
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
// Add sort parameter to filters if selected
@@ -474,7 +475,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
setState(() {
_errorMessage = 'Failed to load library content: $e';
_errorMessage = t.messages.errorLoading(error: e.toString());
_isLoadingItems = false;
});
}
@@ -503,12 +504,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
}
// Public method to refresh content
// Public method to refresh content (for normal navigation)
@override
void refresh() {
_loadLibraries();
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content');
// Reload libraries and clear any selected library/filters
_selectedLibraryKey = null;
_selectedFilters.clear();
_items.clear();
_loadLibraries();
}
Future<void> _toggleLibraryVisibility(PlexLibrary library) async {
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(
context,
@@ -587,39 +598,39 @@ class _LibrariesScreenState extends State<LibrariesScreen>
ContextMenuItem(
value: 'scan',
icon: Icons.refresh,
label: 'Scan Library Files',
label: t.libraries.scanLibraryFiles,
requiresConfirmation: true,
confirmationTitle: 'Scan Library',
confirmationTitle: t.libraries.scanLibrary,
confirmationMessage:
'This will scan "${library.title}" for new files. Continue?',
t.libraries.scanLibraryConfirm(title: library.title),
),
ContextMenuItem(
value: 'analyze',
icon: Icons.analytics_outlined,
label: 'Analyze',
label: t.libraries.analyze,
requiresConfirmation: true,
confirmationTitle: 'Analyze Library',
confirmationTitle: t.libraries.analyzeLibrary,
confirmationMessage:
'This will analyze "${library.title}" for intro markers and other metadata. This may take some time. Continue?',
t.libraries.analyzeLibraryConfirm(title: library.title),
),
ContextMenuItem(
value: 'refresh',
icon: Icons.sync,
label: 'Refresh Metadata',
label: t.libraries.refreshMetadata,
requiresConfirmation: true,
confirmationTitle: 'Refresh Metadata',
confirmationTitle: t.libraries.refreshMetadata,
confirmationMessage:
'This will refresh metadata for all items in "${library.title}". This may take some time. Continue?',
t.libraries.refreshMetadataConfirm(title: library.title),
isDestructive: true,
),
ContextMenuItem(
value: 'empty_trash',
icon: Icons.delete_outline,
label: 'Empty Trash',
label: t.libraries.emptyTrash,
requiresConfirmation: true,
confirmationTitle: 'Empty Trash',
confirmationTitle: t.libraries.emptyTrash,
confirmationMessage:
'This will permanently delete all trashed items in "${library.title}". This action cannot be undone. Continue?',
t.libraries.emptyTrashConfirm(title: library.title),
isDestructive: true,
),
];
@@ -672,14 +683,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
// Show progress indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Scanning "${library.title}"...'),
content: Text(t.messages.libraryScanning(title: library.title)),
duration: const Duration(seconds: 2),
),
);
@@ -690,7 +701,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Library scan started for "${library.title}"'),
content: Text(t.messages.libraryScanStarted(title: library.title)),
duration: const Duration(seconds: 3),
),
);
@@ -700,7 +711,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to scan library: $e'),
content: Text(t.messages.libraryScanFailed(error: e.toString())),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
@@ -714,14 +725,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
// Show progress indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Refreshing metadata for "${library.title}"...'),
content: Text(t.messages.metadataRefreshing(title: library.title)),
duration: const Duration(seconds: 2),
),
);
@@ -732,7 +743,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Metadata refresh started for "${library.title}"'),
content: Text(t.messages.metadataRefreshStarted(title: library.title)),
duration: const Duration(seconds: 3),
),
);
@@ -742,7 +753,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh metadata: $e'),
content: Text(t.messages.metadataRefreshFailed(error: e.toString())),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
@@ -756,14 +767,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
// Show progress indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Emptying trash for "${library.title}"...'),
content: Text(t.libraries.emptyingTrash(title: library.title)),
duration: const Duration(seconds: 2),
),
);
@@ -774,7 +785,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Trash emptied for "${library.title}"'),
content: Text(t.libraries.trashEmptied(title: library.title)),
duration: const Duration(seconds: 3),
),
);
@@ -784,7 +795,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to empty trash: $e'),
content: Text(t.libraries.failedToEmptyTrash(error: e)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
@@ -798,14 +809,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
// Show progress indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Analyzing "${library.title}"...'),
content: Text(t.libraries.analyzing(title: library.title)),
duration: const Duration(seconds: 2),
),
);
@@ -816,7 +827,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Analysis started for "${library.title}"'),
content: Text(t.libraries.analysisStarted(title: library.title)),
duration: const Duration(seconds: 3),
),
);
@@ -826,7 +837,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to analyze library: $e'),
content: Text(t.libraries.failedToAnalyze(error: e)),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
@@ -850,7 +861,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
body: CustomScrollView(
slivers: [
DesktopSliverAppBar(
title: const Text('Libraries'),
title: Text(t.libraries.title),
floating: true,
pinned: true,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
@@ -860,15 +871,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
actions: [
if (_allLibraries.isNotEmpty)
IconButton(
icon: const Icon(
icon: Icon(
Icons.edit,
semanticLabel: 'Manage Libraries',
semanticLabel: t.libraries.manageLibraries,
),
onPressed: _showLibraryManagementSheet,
),
if (_sortOptions.isNotEmpty)
IconButton(
icon: const Icon(Icons.swap_vert, semanticLabel: 'Sort'),
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
onPressed: _showSortBottomSheet,
),
if (_filters.isNotEmpty)
@@ -876,15 +887,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
icon: Badge(
label: Text('${_selectedFilters.length}'),
isLabelVisible: _selectedFilters.isNotEmpty,
child: const Icon(
child: Icon(
Icons.filter_list,
semanticLabel: 'Filters',
semanticLabel: t.libraries.filters,
),
),
onPressed: _showFiltersBottomSheet,
),
IconButton(
icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'),
icon: Icon(Icons.refresh, semanticLabel: t.common.refresh),
onPressed: () => _loadLibraryContent(_selectedLibraryKey!),
),
],
@@ -909,25 +920,25 @@ class _LibrariesScreenState extends State<LibrariesScreen>
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadLibraries,
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
),
)
else if (visibleLibraries.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.video_library_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text('No libraries found'),
const SizedBox(height: 16),
Text(t.libraries.noLibrariesFound),
],
),
),
@@ -1014,21 +1025,21 @@ class _LibrariesScreenState extends State<LibrariesScreen>
ElevatedButton(
onPressed: () =>
_loadLibraryContent(_selectedLibraryKey!),
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
),
)
else if (_items.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.folder_open, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('This library is empty'),
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(t.libraries.thisLibraryIsEmpty),
],
),
),
@@ -1086,7 +1097,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(
'Loading library... (${_items.length} items loaded)',
t.libraries.loadingLibraryWithCount(count: _items.length),
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -1221,7 +1232,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
);
final client = clientProvider.client;
if (client == null) {
throw Exception('No client available');
throw Exception(t.errors.noClientAvailable);
}
final values = await client.getFilterValues(filter.key);
@@ -1322,7 +1333,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
_currentFilter!.filter,
);
return ListTile(
title: const Text('All'),
title: Text(t.libraries.all),
selected: isSelected,
onTap: () {
setState(() {
@@ -1380,9 +1391,9 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
children: [
const Icon(Icons.filter_list),
const SizedBox(width: 12),
const Text(
'Filters',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
Text(
t.libraries.filters,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const Spacer(),
if (_tempSelectedFilters.isNotEmpty)
@@ -1394,7 +1405,7 @@ class _FiltersBottomSheetState extends State<_FiltersBottomSheet> {
_applyFilters();
},
icon: const Icon(Icons.clear_all),
label: const Text('Clear All'),
label: Text(t.libraries.clearAll),
),
IconButton(
icon: const Icon(Icons.close),
@@ -1524,10 +1535,10 @@ class _SortBottomSheetState extends State<_SortBottomSheet> {
),
child: Row(
children: [
const Expanded(
Expanded(
child: Text(
'Sort By',
style: TextStyle(
t.libraries.sortBy,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
@@ -1707,22 +1718,22 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(selectedItem.confirmationTitle ?? 'Confirm Action'),
title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction),
content: Text(
selectedItem.confirmationMessage ??
'Are you sure you want to perform this action?',
t.libraries.confirmActionMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: selectedItem.isDestructive
? TextButton.styleFrom(foregroundColor: Colors.red)
: null,
child: const Text('Confirm'),
child: Text(t.common.confirm),
),
],
),
@@ -1776,10 +1787,10 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
children: [
const Icon(Icons.edit),
const SizedBox(width: 12),
const Expanded(
Expanded(
child: Text(
'Manage Libraries',
style: TextStyle(
t.libraries.manageLibraries,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
@@ -1841,13 +1852,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
: Icons.visibility,
),
onPressed: () => widget.onToggleVisibility(library),
tooltip: isHidden ? 'Show library' : 'Hide library',
tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary,
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () =>
_showLibraryMenuBottomSheet(context, library),
tooltip: 'Library options',
tooltip: t.libraries.libraryOptions,
),
],
),
+6 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
class MergedLicenseEntry {
final String packageName;
@@ -73,7 +74,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Licenses'), pinned: true),
CustomAppBar(title: Text(t.screens.licenses), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -92,7 +93,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
),
subtitle: mergedLicense.licenseEntries.length > 1
? Text(
'${mergedLicense.licenseEntries.length} licenses',
t.licenses.licensesCount(count: mergedLicense.licenseEntries.length),
)
: null,
trailing: const Icon(Icons.chevron_right),
@@ -145,7 +146,7 @@ class _LicenseDetailScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Related Packages',
t.licenses.relatedPackages,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
@@ -177,8 +178,8 @@ class _LicenseDetailScreen extends StatelessWidget {
children: [
Text(
isMultipleLicenses
? 'License ${index + 1}'
: 'License',
? t.licenses.licenseNumber(number: index + 1)
: t.licenses.license,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
+11 -10
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../widgets/desktop_app_bar.dart';
@@ -40,7 +41,7 @@ class _LogsScreenState extends State<LogsScreen> {
_logs = [];
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs cleared')),
SnackBar(content: Text(t.messages.logsCleared)),
);
}
@@ -64,7 +65,7 @@ class _LogsScreenState extends State<LogsScreen> {
}
Clipboard.setData(ClipboardData(text: buffer.toString()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs copied to clipboard')),
SnackBar(content: Text(t.messages.logsCopied)),
);
}
@@ -108,30 +109,30 @@ class _LogsScreenState extends State<LogsScreen> {
body: CustomScrollView(
slivers: [
CustomAppBar(
title: const Text('Logs'),
title: Text(t.screens.logs),
pinned: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadLogs,
tooltip: 'Refresh',
tooltip: t.common.refresh,
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
tooltip: 'Copy All',
tooltip: t.logs.copyLogs,
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: _logs.isNotEmpty ? _clearLogs : null,
tooltip: 'Clear Logs',
tooltip: t.logs.clearLogs,
),
],
),
if (_logs.isEmpty)
const SliverFillRemaining(
SliverFillRemaining(
child: Center(
child: Text('No logs available'),
child: Text(t.messages.noLogsAvailable),
),
)
else
@@ -259,7 +260,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
const SizedBox(height: 8),
if (widget.log.error != null) ...[
Text(
'Error:',
t.logs.error,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
@@ -285,7 +286,7 @@ class _LogEntryCardState extends State<_LogEntryCard> {
if (widget.log.stackTrace != null) ...[
const SizedBox(height: 12),
Text(
'Stack Trace:',
t.logs.stackTrace,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: widget.levelColor,
fontWeight: FontWeight.bold,
+23 -22
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../client/plex_client.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/provider_extensions.dart';
import '../main.dart';
@@ -93,22 +94,22 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
void _invalidateAllScreens() {
appLogger.d('Invalidating all screen data due to profile switch');
// Refresh discover screen
// Full refresh discover screen (reload all content for new profile)
final discoverState = _discoverKey.currentState;
if (discoverState != null && discoverState is Refreshable) {
(discoverState as Refreshable).refresh();
if (discoverState != null) {
(discoverState as dynamic).fullRefresh();
}
// Refresh libraries screen
// Full refresh libraries screen (clear filters and reload for new profile)
final librariesState = _librariesKey.currentState;
if (librariesState != null && librariesState is Refreshable) {
(librariesState as Refreshable).refresh();
if (librariesState != null) {
(librariesState as dynamic).fullRefresh();
}
// Refresh search screen
// Full refresh search screen (clear search for new profile)
final searchState = _searchKey.currentState;
if (searchState != null && searchState is Refreshable) {
(searchState as Refreshable).refresh();
if (searchState != null) {
(searchState as dynamic).fullRefresh();
}
}
@@ -127,26 +128,26 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
_onDiscoverBecameVisible();
}
},
destinations: const [
destinations: [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: t.navigation.home,
),
NavigationDestination(
icon: Icon(Icons.video_library_outlined),
selectedIcon: Icon(Icons.video_library),
label: 'Libraries',
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library),
label: t.navigation.libraries,
),
NavigationDestination(
icon: Icon(Icons.search),
selectedIcon: Icon(Icons.search),
label: 'Search',
icon: const Icon(Icons.search),
selectedIcon: const Icon(Icons.search),
label: t.navigation.search,
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: t.navigation.settings,
),
],
),
+25 -21
View File
@@ -13,6 +13,7 @@ import '../utils/video_player_navigation.dart';
import '../widgets/app_bar_back_button.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/media_context_menu.dart';
import '../i18n/strings.g.dart';
import 'season_detail_screen.dart';
class MediaDetailScreen extends StatefulWidget {
@@ -183,7 +184,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No seasons found')));
).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound)));
}
return;
}
@@ -197,7 +198,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (episodes.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No episodes found in first season')),
SnackBar(content: Text(t.messages.noEpisodesFound)),
);
}
return;
@@ -219,7 +220,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading first episode: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
@@ -650,7 +651,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
await handleShufflePlay(context, metadata);
},
icon: const Icon(Icons.shuffle),
tooltip: 'Shuffle play',
tooltip: t.tooltips.shufflePlay,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
@@ -670,8 +671,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Marked as watched'),
SnackBar(
content: Text(t.messages.markedAsWatched),
),
);
// Update watch state without full rebuild
@@ -680,13 +681,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
},
icon: const Icon(Icons.check),
tooltip: 'Mark as watched',
tooltip: t.tooltips.markAsWatched,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
@@ -705,8 +706,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
if (context.mounted) {
_watchStateChanged = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Marked as unwatched'),
SnackBar(
content: Text(t.messages.markedAsUnwatched),
),
);
// Update watch state without full rebuild
@@ -715,13 +716,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
},
icon: const Icon(Icons.remove_done),
tooltip: 'Mark as unwatched',
tooltip: t.tooltips.markAsUnwatched,
iconSize: 20,
style: IconButton.styleFrom(
minimumSize: const Size(48, 48),
@@ -736,7 +737,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Summary
if (metadata.summary != null) ...[
Text(
'Overview',
t.discover.overview,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -882,7 +883,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No seasons found',
t.messages.noSeasonsFound,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: Colors.grey),
@@ -1025,7 +1026,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
const SizedBox(height: 4),
if (season.leafCount != null)
Text(
'${season.leafCount} episodes',
t.discover.episodeCount(count: season.leafCount.toString()),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: Colors.grey),
),
@@ -1053,7 +1054,10 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
),
const SizedBox(height: 4),
Text(
'${season.viewedLeafCount}/${season.leafCount} watched',
t.discover.watchedProgress(
watched: season.viewedLeafCount.toString(),
total: season.leafCount.toString(),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
@@ -1116,21 +1120,21 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
// Check if episode has been partially watched (viewOffset > 0)
if (episode.viewOffset != null && episode.viewOffset! > 0) {
return 'Resume S$seasonNum, E$episodeNum';
return t.discover.resumeEpisode(season: seasonNum.toString(), episode: episodeNum.toString());
} else {
return 'Play S$seasonNum, E$episodeNum';
return t.discover.playEpisode(season: seasonNum.toString(), episode: episodeNum.toString());
}
} else {
// No on deck episode, will play first episode
return 'Play S1, E1';
return t.discover.playEpisode(season: '1', episode: '1');
}
}
// For movies or episodes, check if partially watched
if (metadata.viewOffset != null && metadata.viewOffset! > 0) {
return 'Resume';
return t.discover.resume;
}
return 'Play';
return t.discover.play;
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import '../providers/user_profile_provider.dart';
import '../utils/provider_extensions.dart';
import '../widgets/profile_list_tile.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart';
class ProfileSwitchScreen extends StatelessWidget {
const ProfileSwitchScreen({super.key});
@@ -16,7 +17,7 @@ class ProfileSwitchScreen extends StatelessWidget {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Switch Profile')),
CustomAppBar(title: Text(t.screens.switchProfile)),
SliverFillRemaining(
child: Consumer<UserProfileProvider>(
builder: (context, userProvider, child) {
@@ -41,7 +42,7 @@ class ProfileSwitchScreen extends StatelessWidget {
onPressed: () {
userProvider.refreshCurrentUser();
},
child: const Text('Retry'),
child: Text(t.common.retry),
),
],
),
@@ -115,7 +116,7 @@ class ProfileSwitchScreen extends StatelessWidget {
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to switch to ${user.displayName}'),
content: Text(t.errors.failedToSwitchProfile(displayName: user.displayName)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
+20 -5
View File
@@ -6,10 +6,12 @@ import '../models/plex_metadata.dart';
import '../services/settings_service.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../mixins/refreshable.dart';
import '../mixins/item_updatable.dart';
import '../i18n/strings.g.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -107,7 +109,7 @@ class _SearchScreenState extends State<SearchScreen>
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Search failed: $e')));
).showSnackBar(SnackBar(content: Text(t.errors.searchFailed(error: e))));
}
}
}
@@ -120,6 +122,19 @@ class _SearchScreenState extends State<SearchScreen>
}
}
// Public method to fully reload all content (for profile switches)
void fullRefresh() {
appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading');
// Clear search results and search text for new profile
_searchController.clear();
setState(() {
_searchResults.clear();
_isSearching = false;
_hasSearched = false;
_lastSearchedQuery = '';
});
}
@override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
final index = _searchResults.indexWhere(
@@ -136,13 +151,13 @@ class _SearchScreenState extends State<SearchScreen>
body: SafeArea(
child: CustomScrollView(
slivers: [
DesktopSliverAppBar(title: const Text('Search'), floating: true),
DesktopSliverAppBar(title: Text(t.screens.search), floating: true),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: _searchController,
hintText: 'Search movies, shows, music...',
hintText: t.search.hint,
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
@@ -198,14 +213,14 @@ class _SearchScreenState extends State<SearchScreen>
),
const SizedBox(height: 16),
Text(
'No results found',
t.messages.noResultsFound,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
'Try a different search term',
t.search.tryDifferentTerm,
style: TextStyle(color: Colors.grey.shade600),
),
],
+2 -1
View File
@@ -10,6 +10,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/media_context_menu.dart';
import '../mixins/item_updatable.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
class SeasonDetailScreen extends StatefulWidget {
final PlexMetadata season;
@@ -100,7 +101,7 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
),
const SizedBox(height: 16),
Text(
'No episodes found',
t.messages.noEpisodesFoundGeneral,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: tokens(context).textMuted,
),
+88 -15
View File
@@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../i18n/strings.g.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../services/server_connection_service.dart';
@@ -27,6 +30,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
bool _isLoading = true;
String? _errorMessage;
String? _currentServerUrl;
List<Map<String, dynamic>>? _debugServerData;
@override
void initState() {
@@ -52,12 +56,58 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
setState(() {
_servers = servers;
_isLoading = false;
_debugServerData = null; // Clear any previous debug data
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load servers: $e';
_errorMessage = _getErrorMessage(e);
_isLoading = false;
// Store debug data if it's a parsing exception
if (e is ServerParsingException) {
_debugServerData = e.invalidServerData;
} else {
_debugServerData = null;
}
});
appLogger.e('Failed to load servers', error: e);
}
}
String _getErrorMessage(dynamic error) {
if (error is ServerParsingException) {
return t.serverSelection.malformedServerData(count: error.invalidServerData.length);
} else if (error is FormatException) {
// Handle JSON parsing errors with more user-friendly messages
if (error.message.contains('Invalid server data')) {
return t.serverSelection.incompleteServerInfo;
} else if (error.message.contains('Invalid connection data')) {
return t.serverSelection.incompleteConnectionInfo;
}
return t.serverSelection.malformedServerInfo(message: error.message);
} else if (error.toString().contains('SocketException') ||
error.toString().contains('TimeoutException')) {
return t.serverSelection.networkConnectionFailed;
} else if (error.toString().contains('401') ||
error.toString().contains('Unauthorized')) {
return t.serverSelection.authenticationFailed;
} else if (error.toString().contains('404') ||
error.toString().contains('Not Found')) {
return t.serverSelection.plexServiceUnavailable;
}
return t.serverSelection.failedToLoadServers(error: error.toString());
}
Future<void> _copyDebugDataToClipboard() async {
if (_debugServerData == null) return;
final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData);
await Clipboard.setData(ClipboardData(text: jsonString));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.serverSelection.serverDebugCopied)),
);
}
}
@@ -67,13 +117,13 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
builder: (context) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Connecting to server...'),
Text(t.serverSelection.connectingToServer),
],
),
),
@@ -157,7 +207,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
// Show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error ?? 'Connection failed')),
SnackBar(content: Text(result.error ?? t.errors.connectionFailedGeneric)),
);
}
}
@@ -166,7 +216,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to connect to server: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
appLogger.e('Server selection failed', error: e);
@@ -190,7 +240,7 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Select Server')),
CustomAppBar(title: Text(t.screens.selectServer)),
SliverFillRemaining(
child: _isLoading
? const Center(child: CircularProgressIndicator())
@@ -199,23 +249,46 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadServers,
child: const Text('Retry'),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _loadServers,
child: Text(t.common.retry),
),
if (_debugServerData != null) ...[
const SizedBox(width: 16),
OutlinedButton.icon(
onPressed: _copyDebugDataToClipboard,
icon: const Icon(Icons.copy),
label: Text(t.serverSelection.copyDebugData),
),
],
],
),
if (_debugServerData != null) ...[
const SizedBox(height: 12),
Text(
'Debug data available for ${_debugServerData!.length} server(s)',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
)
: _servers == null || _servers!.isEmpty
? const Center(child: Text('No servers found'))
? Center(child: Text(t.serverSelection.noServersFound))
: ListView.builder(
itemCount: _servers!.length,
padding: const EdgeInsets.all(16),
+242 -136
View File
@@ -9,6 +9,7 @@ import '../services/keyboard_shortcuts_service.dart';
import '../services/update_service.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/hotkey_recorder_widget.dart';
import '../i18n/strings.g.dart';
import 'about_screen.dart';
import 'logs_screen.dart';
import 'subtitle_styling_screen.dart';
@@ -66,7 +67,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Settings'), pinned: true),
CustomAppBar(title: Text(t.settings.title), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -103,7 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Appearance',
t.settings.appearance,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -113,18 +114,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, themeProvider, child) {
return ListTile(
leading: Icon(themeProvider.themeModeIcon),
title: const Text('Theme'),
title: Text(t.settings.theme),
subtitle: Text(themeProvider.themeModeDisplayName),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(themeProvider),
);
},
),
ListTile(
leading: const Icon(Icons.language),
title: Text(t.settings.language),
subtitle: Text(
_getLanguageDisplayName(LocaleSettings.currentLocale),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return ListTile(
leading: const Icon(Icons.grid_view),
title: const Text('Library Density'),
title: Text(t.settings.libraryDensity),
subtitle: Text(settingsProvider.libraryDensityDisplayName),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLibraryDensityDialog(),
@@ -135,8 +145,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return ListTile(
leading: const Icon(Icons.view_list),
title: const Text('View Mode'),
subtitle: Text(settingsProvider.viewMode == settings.ViewMode.grid ? 'Grid' : 'List'),
title: Text(t.settings.viewMode),
subtitle: Text(
settingsProvider.viewMode == settings.ViewMode.grid
? t.settings.gridView
: t.settings.listView,
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showViewModeDialog(),
);
@@ -146,10 +160,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.image),
title: const Text('Use Season Posters'),
subtitle: const Text(
'Show season poster instead of series poster for episodes',
),
title: Text(t.settings.useSeasonPosters),
subtitle: Text(t.settings.useSeasonPostersDescription),
value: settingsProvider.useSeasonPoster,
onChanged: (value) async {
await settingsProvider.setUseSeasonPoster(value);
@@ -161,10 +173,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.featured_play_list),
title: const Text('Show Hero Section'),
subtitle: const Text(
'Display featured content carousel on home screen',
),
title: Text(t.settings.showHeroSection),
subtitle: Text(t.settings.showHeroSectionDescription),
value: settingsProvider.showHeroSection,
onChanged: (value) async {
await settingsProvider.setShowHeroSection(value);
@@ -185,7 +195,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Video Playback',
t.settings.videoPlayback,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -193,8 +203,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
SwitchListTile(
secondary: const Icon(Icons.hardware),
title: const Text('Hardware Decoding'),
subtitle: const Text('Use hardware acceleration when available'),
title: Text(t.settings.hardwareDecoding),
subtitle: Text(t.settings.hardwareDecodingDescription),
value: _enableHardwareDecoding,
onChanged: (value) async {
setState(() {
@@ -205,15 +215,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.memory),
title: const Text('Buffer Size'),
subtitle: Text('${_bufferSize}MB'),
title: Text(t.settings.bufferSize),
subtitle: Text(
t.settings.bufferSizeMB(size: _bufferSize.toString()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showBufferSizeDialog(),
),
ListTile(
leading: const Icon(Icons.subtitles),
title: const Text('Subtitle Styling'),
subtitle: const Text('Customize subtitle appearance'),
title: Text(t.settings.subtitleStyling),
subtitle: Text(t.settings.subtitleStylingDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -226,22 +238,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.replay_10),
title: const Text('Small Skip Duration'),
subtitle: Text('$_seekTimeSmall seconds'),
title: Text(t.settings.smallSkipDuration),
subtitle: Text(
t.settings.secondsUnit(seconds: _seekTimeSmall.toString()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSeekTimeSmallDialog(),
),
ListTile(
leading: const Icon(Icons.replay_30),
title: const Text('Large Skip Duration'),
subtitle: Text('$_seekTimeLarge seconds'),
title: Text(t.settings.largeSkipDuration),
subtitle: Text(
t.settings.secondsUnit(seconds: _seekTimeLarge.toString()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSeekTimeLargeDialog(),
),
ListTile(
leading: const Icon(Icons.bedtime),
title: const Text('Default Sleep Timer'),
subtitle: Text('$_sleepTimerDuration minutes'),
title: Text(t.settings.defaultSleepTimer),
subtitle: Text(
t.settings.minutesUnit(minutes: _sleepTimerDuration.toString()),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showSleepTimerDurationDialog(),
),
@@ -258,7 +276,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Shuffle Play',
t.settings.shufflePlay,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -268,10 +286,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: const Text('Unwatched Only'),
subtitle: const Text(
'Only include unwatched episodes in shuffle queue',
),
title: Text(t.settings.unwatchedOnly),
subtitle: Text(t.settings.unwatchedOnlyDescription),
value: settingsProvider.shuffleUnwatchedOnly,
onChanged: (value) async {
await settingsProvider.setShuffleUnwatchedOnly(value);
@@ -283,10 +299,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.shuffle),
title: const Text('Shuffle Order Navigation'),
subtitle: const Text(
'Next/previous buttons follow shuffled order',
),
title: Text(t.settings.shuffleOrderNavigation),
subtitle: Text(t.settings.shuffleOrderNavigationDescription),
value: settingsProvider.shuffleOrderNavigation,
onChanged: (value) async {
await settingsProvider.setShuffleOrderNavigation(value);
@@ -298,10 +312,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const Icon(Icons.loop),
title: const Text('Loop Shuffle Queue'),
subtitle: const Text(
'Restart queue when reaching the end',
),
title: Text(t.settings.loopShuffleQueue),
subtitle: Text(t.settings.loopShuffleQueueDescription),
value: settingsProvider.shuffleLoopQueue,
onChanged: (value) async {
await settingsProvider.setShuffleLoopQueue(value);
@@ -322,7 +334,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keyboard Shortcuts',
t.settings.keyboardShortcuts,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -330,8 +342,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.keyboard),
title: const Text('Video Player Controls'),
subtitle: const Text('Customize keyboard shortcuts'),
title: Text(t.settings.videoPlayerControls),
subtitle: Text(t.settings.keyboardShortcutsDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showKeyboardShortcutsDialog(),
),
@@ -348,7 +360,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Advanced',
t.settings.advanced,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -356,8 +368,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
SwitchListTile(
secondary: const Icon(Icons.bug_report),
title: const Text('Debug Logging'),
subtitle: const Text('Enable detailed logging for troubleshooting'),
title: Text(t.settings.debugLogging),
subtitle: Text(t.settings.debugLoggingDescription),
value: _enableDebugLogging,
onChanged: (value) async {
setState(() {
@@ -368,8 +380,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.article),
title: const Text('View Logs'),
subtitle: const Text('View application logs'),
title: Text(t.settings.viewLogs),
subtitle: Text(t.settings.viewLogsDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -380,15 +392,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
ListTile(
leading: const Icon(Icons.cleaning_services),
title: const Text('Clear Cache'),
subtitle: const Text('Free up storage space'),
title: Text(t.settings.clearCache),
subtitle: Text(t.settings.clearCacheDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showClearCacheDialog(),
),
ListTile(
leading: const Icon(Icons.restore),
title: const Text('Reset Settings'),
subtitle: const Text('Reset all settings to defaults'),
title: Text(t.settings.resetSettings),
subtitle: Text(t.settings.resetSettingsDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showResetSettingsDialog(),
),
@@ -407,7 +419,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Updates',
t.settings.updates,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@@ -418,10 +430,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
hasUpdate ? Icons.system_update : Icons.check_circle,
color: hasUpdate ? Colors.orange : null,
),
title: Text(hasUpdate ? 'Update Available' : 'Check for Updates'),
title: Text(
hasUpdate
? t.settings.updateAvailable
: t.settings.checkForUpdates,
),
subtitle: hasUpdate
? Text('Version ${_updateInfo!['latestVersion']} is available')
: const Text('Check for the latest version on GitHub'),
? Text(
t.update.versionAvailable(
version: _updateInfo!['latestVersion'],
),
)
: Text(t.update.checkFailed),
trailing: _isCheckingForUpdate
? const SizedBox(
width: 24,
@@ -448,8 +468,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Card(
child: ListTile(
leading: const Icon(Icons.info),
title: const Text('About'),
subtitle: const Text('App information and licenses'),
title: Text(t.settings.about),
subtitle: Text(t.settings.aboutDescription),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
@@ -466,7 +486,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Theme'),
title: Text(t.settings.theme),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -476,8 +496,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('System'),
subtitle: const Text('Follow system settings'),
title: Text(t.settings.systemTheme),
subtitle: Text(t.settings.systemThemeDescription),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.system);
Navigator.pop(context);
@@ -489,7 +509,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Light'),
title: Text(t.settings.lightTheme),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.light);
Navigator.pop(context);
@@ -501,7 +521,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Dark'),
title: Text(t.settings.darkTheme),
onTap: () {
themeProvider.setThemeMode(settings.ThemeMode.dark);
Navigator.pop(context);
@@ -512,7 +532,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
],
);
@@ -527,7 +547,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Buffer Size'),
title: Text(t.settings.bufferSize),
content: Column(
mainAxisSize: MainAxisSize.min,
children: options.map((size) {
@@ -551,7 +571,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
],
);
@@ -569,24 +589,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Small Skip Duration'),
title: Text(t.settings.smallSkipDuration),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Seconds',
hintText: 'Enter duration (1-120)',
labelText: t.settings.secondsLabel,
hintText: t.settings.durationHint(min: 1, max: 120),
errorText: errorText,
suffixText: 's',
suffixText: t.settings.secondsShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = 'Please enter a valid number';
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 1 || parsed > 120) {
errorText = 'Duration must be between 1 and 120 seconds';
errorText = t.settings.validationErrorDuration(
min: 1,
max: 120,
unit: t.settings.secondsLabel.toLowerCase(),
);
} else {
errorText = null;
}
@@ -596,7 +620,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
@@ -613,7 +637,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
},
child: const Text('Save'),
child: Text(t.common.save),
),
],
);
@@ -633,24 +657,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Large Skip Duration'),
title: Text(t.settings.largeSkipDuration),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Seconds',
hintText: 'Enter duration (1-120)',
labelText: t.settings.secondsLabel,
hintText: t.settings.durationHint(min: 1, max: 120),
errorText: errorText,
suffixText: 's',
suffixText: t.settings.secondsShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = 'Please enter a valid number';
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 1 || parsed > 120) {
errorText = 'Duration must be between 1 and 120 seconds';
errorText = t.settings.validationErrorDuration(
min: 1,
max: 120,
unit: t.settings.secondsLabel.toLowerCase(),
);
} else {
errorText = null;
}
@@ -660,7 +688,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
@@ -677,7 +705,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
},
child: const Text('Save'),
child: Text(t.common.save),
),
],
);
@@ -699,24 +727,28 @@ class _SettingsScreenState extends State<SettingsScreen> {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text('Default Sleep Timer'),
title: Text(t.settings.defaultSleepTimer),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Minutes',
hintText: 'Enter duration (5-180)',
labelText: t.settings.minutesLabel,
hintText: t.settings.durationHint(min: 5, max: 180),
errorText: errorText,
suffixText: 'min',
suffixText: t.settings.minutesShort,
),
autofocus: true,
onChanged: (value) {
final parsed = int.tryParse(value);
setDialogState(() {
if (parsed == null) {
errorText = 'Please enter a valid number';
errorText = t.settings.validationErrorEnterNumber;
} else if (parsed < 5 || parsed > 180) {
errorText = 'Duration must be between 5 and 180 minutes';
errorText = t.settings.validationErrorDuration(
min: 5,
max: 180,
unit: t.settings.minutesLabel.toLowerCase(),
);
} else {
errorText = null;
}
@@ -726,7 +758,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
@@ -741,7 +773,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
},
child: const Text('Save'),
child: Text(t.common.save),
),
],
);
@@ -766,14 +798,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Clear Cache'),
content: const Text(
'This will clear all cached images and data. The app may take longer to load content after clearing the cache.',
),
title: Text(t.settings.clearCache),
content: Text(t.settings.clearCacheDescription),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
@@ -783,11 +813,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
navigator.pop();
messenger.showSnackBar(
const SnackBar(content: Text('Cache cleared successfully')),
SnackBar(content: Text(t.settings.clearCacheSuccess)),
);
}
},
child: const Text('Clear'),
child: Text(t.common.clear),
),
],
);
@@ -800,14 +830,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Reset Settings'),
content: const Text(
'This will reset all settings to their default values. This action cannot be undone.',
),
title: Text(t.settings.resetSettings),
content: Text(t.settings.resetSettingsDescription),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () async {
@@ -818,15 +846,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) {
navigator.pop();
messenger.showSnackBar(
const SnackBar(
content: Text('Settings reset successfully'),
),
SnackBar(content: Text(t.settings.resetSettingsSuccess)),
);
// Reload settings
_loadSettings();
}
},
child: const Text('Reset'),
child: Text(t.common.reset),
),
],
);
@@ -834,6 +860,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
String _getLanguageDisplayName(AppLocale locale) {
switch (locale) {
case AppLocale.en:
return 'English';
case AppLocale.sv:
return 'Svenska';
}
}
void _showLanguageDialog() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(t.settings.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: AppLocale.values.map((locale) {
final isSelected = LocaleSettings.currentLocale == locale;
return ListTile(
title: Text(_getLanguageDisplayName(locale)),
leading: Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
tileColor: isSelected
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3)
: null,
onTap: () async {
// Save the locale to settings
await _settingsService.setAppLocale(locale);
// Set the locale immediately
LocaleSettings.setLocale(locale);
// Close dialog
if (context.mounted) {
Navigator.pop(context);
}
// Trigger app-wide rebuild by restarting the app
if (context.mounted) {
_restartApp();
}
},
);
}).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(t.common.cancel),
),
],
);
},
);
}
void _restartApp() {
// Navigate to the root and remove all previous routes
Navigator.pushNamedAndRemoveUntil(
context,
'/',
(route) => false,
);
}
Future<void> _checkForUpdates() async {
setState(() {
_isCheckingForUpdate = true;
@@ -851,8 +947,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
// Show "no updates" message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('You are on the latest version'),
SnackBar(
content: Text(t.update.latestVersion),
duration: Duration(seconds: 2),
),
);
@@ -865,8 +961,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to check for updates'),
SnackBar(
content: Text(t.update.checkFailed),
duration: Duration(seconds: 2),
),
);
@@ -881,18 +977,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Update Available'),
title: Text(t.settings.updateAvailable),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Version ${_updateInfo!['latestVersion']} is available',
t.update.versionAvailable(
version: _updateInfo!['latestVersion'],
),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Current: ${_updateInfo!['currentVersion']}',
t.update.currentVersion(
version: _updateInfo!['currentVersion'],
),
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -900,7 +1000,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(t.common.close),
),
FilledButton(
onPressed: () async {
@@ -910,7 +1010,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
if (context.mounted) Navigator.pop(context);
},
child: const Text('View Release'),
child: Text(t.update.viewRelease),
),
],
);
@@ -926,7 +1026,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
return AlertDialog(
title: const Text('Library Density'),
title: Text(t.settings.libraryDensity),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -936,8 +1036,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Compact'),
subtitle: const Text('Smaller cards, more items visible'),
title: Text(t.settings.compact),
subtitle: Text(t.settings.compactDescription),
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.compact,
@@ -951,8 +1051,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Normal'),
subtitle: const Text('Default size'),
title: Text(t.settings.normal),
subtitle: Text(t.settings.normalDescription),
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.normal,
@@ -967,8 +1067,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Comfortable'),
subtitle: const Text('Larger cards, fewer items visible'),
title: Text(t.settings.comfortable),
subtitle: Text(t.settings.comfortableDescription),
onTap: () async {
await settingsProvider.setLibraryDensity(
settings.LibraryDensity.comfortable,
@@ -981,7 +1081,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
],
);
@@ -999,7 +1099,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Consumer<SettingsProvider>(
builder: (context, provider, child) {
return AlertDialog(
title: const Text('View Mode'),
title: Text(t.settings.viewMode),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1009,8 +1109,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('Grid'),
subtitle: const Text('Display items in a grid layout'),
title: Text(t.settings.gridView),
subtitle: Text(t.settings.gridViewDescription),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.grid,
@@ -1024,8 +1124,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: const Text('List'),
subtitle: const Text('Display items in a list layout'),
title: Text(t.settings.listView),
subtitle: Text(t.settings.listViewDescription),
onTap: () async {
await settingsProvider.setViewMode(
settings.ViewMode.list,
@@ -1038,7 +1138,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
],
);
@@ -1087,7 +1187,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
body: CustomScrollView(
slivers: [
CustomAppBar(
title: const Text('Keyboard Shortcuts'),
title: Text(t.settings.keyboardShortcuts),
pinned: true,
actions: [
TextButton(
@@ -1097,13 +1197,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
await _loadHotkeys();
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Shortcuts reset to defaults'),
),
SnackBar(content: Text(t.settings.shortcutsReset)),
);
}
},
child: const Text('Reset'),
child: Text(t.common.reset),
),
],
),
@@ -1169,7 +1267,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
messenger.showSnackBar(
SnackBar(
content: Text(
'Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}',
t.settings.shortcutAlreadyAssigned(
action: widget.keyboardService.getActionDisplayName(
existingAction,
),
),
),
),
);
@@ -1190,7 +1292,11 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> {
messenger.showSnackBar(
SnackBar(
content: Text(
'Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}',
t.settings.shortcutUpdated(
action: widget.keyboardService.getActionDisplayName(
action,
),
),
),
),
);
+16 -17
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import '../i18n/strings.g.dart';
import '../services/settings_service.dart';
import '../widgets/desktop_app_bar.dart';
@@ -51,13 +52,13 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
}
String _colorToHex(Color color) {
return '#${color.red.toRadixString(16).padLeft(2, '0')}${color.green.toRadixString(16).padLeft(2, '0')}${color.blue.toRadixString(16).padLeft(2, '0')}'.toUpperCase();
return '#${((color.r * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.g * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}${((color.b * 255.0).round() & 0xff).toRadixString(16).padLeft(2, '0')}'.toUpperCase();
}
Future<void> _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async {
Color initialColor = _hexToColor(currentColor);
final Color? selectedColor = await showColorPickerDialog(
final Color selectedColor = await showColorPickerDialog(
context,
initialColor,
title: Text(title),
@@ -85,10 +86,8 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
),
);
if (selectedColor != null) {
final hexColor = _colorToHex(selectedColor);
onColorSelected(hexColor);
}
final hexColor = _colorToHex(selectedColor);
onColorSelected(hexColor);
}
@override
@@ -100,7 +99,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
return Scaffold(
body: CustomScrollView(
slivers: [
const CustomAppBar(title: Text('Subtitle Styling'), pinned: true),
CustomAppBar(title: Text(t.screens.subtitleStyling), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
@@ -123,7 +122,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Styling Options',
t.subtitlingStyling.stylingOptions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -136,7 +135,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Font Size'),
Text(t.subtitlingStyling.fontSize),
Text('$_fontSize'),
],
),
@@ -179,11 +178,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
borderRadius: BorderRadius.circular(4),
),
),
title: const Text('Text Color'),
title: Text(t.subtitlingStyling.textColor),
subtitle: Text(_textColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker('Text Color', _textColor, (color) {
_showColorPicker(t.subtitlingStyling.textColor, _textColor, (color) {
setState(() {
_textColor = color;
});
@@ -201,7 +200,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Border Size'),
Text(t.subtitlingStyling.borderSize),
Text('$_borderSize'),
],
),
@@ -244,11 +243,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
borderRadius: BorderRadius.circular(4),
),
),
title: const Text('Border Color'),
title: Text(t.subtitlingStyling.borderColor),
subtitle: Text(_borderColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker('Border Color', _borderColor, (color) {
_showColorPicker(t.subtitlingStyling.borderColor, _borderColor, (color) {
setState(() {
_borderColor = color;
});
@@ -266,7 +265,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Background Opacity'),
Text(t.subtitlingStyling.backgroundOpacity),
Text('$_backgroundOpacity%'),
],
),
@@ -309,11 +308,11 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
borderRadius: BorderRadius.circular(4),
),
),
title: const Text('Background Color'),
title: Text(t.subtitlingStyling.backgroundColor),
subtitle: Text(_backgroundColor),
trailing: const Icon(Icons.chevron_right),
onTap: () {
_showColorPicker('Background Color', _backgroundColor, (color) {
_showColorPicker(t.subtitlingStyling.backgroundColor, _backgroundColor, (color) {
setState(() {
_backgroundColor = color;
});
+17 -33
View File
@@ -21,6 +21,7 @@ import '../utils/platform_detector.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../widgets/video_controls/video_controls.dart';
import '../i18n/strings.g.dart';
class VideoPlayerScreen extends StatefulWidget {
final PlexMetadata metadata;
@@ -180,9 +181,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
// Get the video URL and start playback
_startPlayback();
// Load available media versions
_loadMediaVersions();
// Set fullscreen mode and orientation based on rotation lock setting
if (mounted) {
try {
@@ -303,18 +301,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
throw Exception('No client available');
}
// Get the direct file URL from the server using the selected media index
final videoUrl = await client.getVideoUrl(
// Get consolidated playback data (URL, media info, and versions) in a single API call
final playbackData = await client.getVideoPlaybackData(
widget.metadata.ratingKey,
mediaIndex: widget.selectedMediaIndex,
);
if (videoUrl != null) {
// Fetch media info to check for external subtitle tracks
final mediaInfo = await client.getMediaInfo(
widget.metadata.ratingKey,
mediaIndex: widget.selectedMediaIndex,
);
if (playbackData.hasValidVideoUrl) {
final videoUrl = playbackData.videoUrl!;
final mediaInfo = playbackData.mediaInfo;
// Update available versions from the playback data
if (mounted) {
setState(() {
_availableVersions = playbackData.availableVersions;
});
}
// Build list of external subtitle tracks for media_kit
final externalSubtitles = <SubtitleTrack>[];
@@ -420,7 +422,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not find video file')),
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
);
}
}
@@ -428,29 +430,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
}
}
}
/// Load available media versions for this item
Future<void> _loadMediaVersions() async {
try {
final clientProvider = context.plexClient;
final client = clientProvider.client;
if (client == null) return;
final versions = await client.getMediaVersions(widget.metadata.ratingKey);
if (mounted) {
setState(() {
_availableVersions = versions;
});
}
} catch (e) {
appLogger.e('Error loading media versions: $e');
}
}
/// Cycle through BoxFit modes: contain → cover → fill → contain (for button)
void _cycleBoxFitMode() {
setState(() {
@@ -1497,7 +1481,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
vertical: 16,
),
),
child: const Text('Cancel'),
child: Text(t.dialog.cancel),
),
const SizedBox(width: 16),
FilledButton(
@@ -1510,7 +1494,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> {
vertical: 16,
),
),
child: const Text('Play Now'),
child: Text(t.dialog.playNow),
),
],
),
+109 -16
View File
@@ -143,10 +143,30 @@ class PlexAuthService {
final List<dynamic> resources = response.data as List<dynamic>;
// Filter for server resources and map to PlexServer objects
return resources
.where((r) => r['provides'] == 'server')
.map((r) => PlexServer.fromJson(r as Map<String, dynamic>))
.toList();
final servers = <PlexServer>[];
final invalidServers = <Map<String, dynamic>>[];
for (final resource in resources.where((r) => r['provides'] == 'server')) {
try {
final server = PlexServer.fromJson(resource as Map<String, dynamic>);
servers.add(server);
} catch (e) {
// Collect invalid servers for debugging
invalidServers.add(resource as Map<String, dynamic>);
continue;
}
}
// If we have invalid servers but some valid ones, that's okay
// If we have no valid servers but some invalid ones, throw with debug info
if (servers.isEmpty && invalidServers.isNotEmpty) {
throw ServerParsingException(
'No valid servers found. All ${invalidServers.length} server(s) have malformed data.',
invalidServers,
);
}
return servers;
}
/// Get user information
@@ -249,20 +269,35 @@ class PlexServer {
});
factory PlexServer.fromJson(Map<String, dynamic> json) {
// Validate required fields first
if (!_isValidServerJson(json)) {
throw FormatException('Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)');
}
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
final connections = <PlexConnection>[];
// Parse connections and generate HTTP fallbacks for HTTPS connections
for (final c in connectionsJson) {
final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
connections.add(connection);
try {
final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
connections.add(connection);
// Generate HTTP fallback for HTTPS connections
if (connection.protocol == 'https') {
connections.add(connection.toHttpFallback());
// Generate HTTP fallback for HTTPS connections
if (connection.protocol == 'https') {
connections.add(connection.toHttpFallback());
}
} catch (e) {
// Skip invalid connections rather than failing the entire server
continue;
}
}
// If no valid connections were parsed, this server is unusable
if (connections.isEmpty) {
throw FormatException('Server has no valid connections');
}
DateTime? lastSeenAt;
if (json['lastSeenAt'] != null) {
try {
@@ -273,9 +308,9 @@ class PlexServer {
}
return PlexServer(
name: json['name'] as String,
clientIdentifier: json['clientIdentifier'] as String,
accessToken: json['accessToken'] as String,
name: json['name'] as String, // Safe because validated above
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
accessToken: json['accessToken'] as String, // Safe because validated above
connections: connections,
owned: json['owned'] as bool? ?? false,
product: json['product'] as String?,
@@ -285,6 +320,27 @@ class PlexServer {
);
}
/// Validates that server JSON contains all required fields with correct types
static bool _isValidServerJson(Map<String, dynamic> json) {
// Check for required string fields
if (json['name'] is! String || (json['name'] as String).isEmpty) {
return false;
}
if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
return false;
}
if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
return false;
}
// Check for connections array
if (json['connections'] is! List || (json['connections'] as List).isEmpty) {
return false;
}
return true;
}
Map<String, dynamic> toJson() {
return {
'name': name,
@@ -516,17 +572,43 @@ 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)');
}
return PlexConnection(
protocol: json['protocol'] as String,
address: json['address'] as String,
port: json['port'] as int,
uri: json['uri'] as String,
protocol: json['protocol'] as String, // Safe because validated above
address: json['address'] as String, // Safe because validated above
port: json['port'] as int, // Safe because validated above
uri: json['uri'] as String, // Safe because validated above
local: json['local'] as bool? ?? false,
relay: json['relay'] as bool? ?? false,
ipv6: json['IPv6'] as bool? ?? false,
);
}
/// Validates that connection JSON contains all required fields with correct types
static bool _isValidConnectionJson(Map<String, dynamic> json) {
// Check for required string fields
if (json['protocol'] is! String || (json['protocol'] as String).isEmpty) {
return false;
}
if (json['address'] is! String || (json['address'] as String).isEmpty) {
return false;
}
if (json['uri'] is! String || (json['uri'] as String).isEmpty) {
return false;
}
// Check for required port (integer)
if (json['port'] is! int) {
return false;
}
return true;
}
Map<String, dynamic> toJson() {
return {
'protocol': protocol,
@@ -565,3 +647,14 @@ class PlexConnection {
);
}
}
/// Custom exception for server parsing errors that includes debug data
class ServerParsingException implements Exception {
final String message;
final List<Map<String, dynamic>> invalidServerData;
ServerParsingException(this.message, this.invalidServerData);
@override
String toString() => message;
}
+18
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:plezy/utils/app_logger.dart';
import '../i18n/strings.g.dart';
enum ThemeMode { system, light, dark }
@@ -40,6 +41,7 @@ class SettingsService {
static const String _keyShuffleUnwatchedOnly = 'shuffle_unwatched_only';
static const String _keyShuffleOrderNavigation = 'shuffle_order_navigation';
static const String _keyShuffleLoopQueue = 'shuffle_loop_queue';
static const String _keyAppLocale = 'app_locale';
static SettingsService? _instance;
late SharedPreferences _prefs;
@@ -780,6 +782,21 @@ class SettingsService {
}
}
// App Locale
Future<void> setAppLocale(AppLocale locale) async {
await _prefs.setString(_keyAppLocale, locale.languageCode);
}
AppLocale getAppLocale() {
final localeString = _prefs.getString(_keyAppLocale);
if (localeString == null) return AppLocale.en; // Default to English
return AppLocale.values.firstWhere(
(locale) => locale.languageCode == localeString,
orElse: () => AppLocale.en,
);
}
// Shuffle Play Settings
/// Shuffle Unwatched Only - Filter shuffle queue to unwatched episodes only
@@ -840,6 +857,7 @@ class SettingsService {
_prefs.remove(_keyShuffleUnwatchedOnly),
_prefs.remove(_keyShuffleOrderNavigation),
_prefs.remove(_keyShuffleLoopQueue),
_prefs.remove(_keyAppLocale),
]);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import '../providers/playback_state_provider.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart';
import '../i18n/strings.g.dart';
/// Handle shuffle play action for shows and seasons
///
@@ -84,7 +85,7 @@ Future<void> handleShufflePlay(
if (episodes.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No episodes found')),
SnackBar(content: Text(t.messages.noEpisodesFound)),
);
}
return;
@@ -108,7 +109,7 @@ Future<void> handleShufflePlay(
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error starting shuffle play: $e')),
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../models/plex_home_user.dart';
import '../i18n/strings.g.dart';
import 'provider_extensions.dart';
class UserSwitchingUtils {
@@ -17,7 +18,7 @@ class UserSwitchingUtils {
} else if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to switch to ${user.displayName}'),
content: Text(t.messages.failedToSwitchProfile(displayName: user.displayName)),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
+5 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../utils/platform_detector.dart';
import '../i18n/strings.g.dart';
/// A menu action item for context menus
class ContextMenuItem {
@@ -67,14 +68,14 @@ class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: isDestructive
? TextButton.styleFrom(foregroundColor: Colors.red)
: null,
child: const Text('Confirm'),
child: Text(t.common.confirm),
),
],
),
@@ -165,10 +166,10 @@ class _ContextMenuWrapperState extends State<ContextMenuWrapper> {
if (selectedItem.requiresConfirmation) {
final confirmed = await _showConfirmationDialog(
title: selectedItem.confirmationTitle ?? 'Confirm Action',
title: selectedItem.confirmationTitle ?? t.dialog.confirmAction,
message:
selectedItem.confirmationMessage ??
'Are you sure you want to perform this action?',
t.dialog.areYouSure,
isDestructive: selectedItem.isDestructive,
);
+28 -27
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../models/plex_file_info.dart';
import '../i18n/strings.g.dart';
class FileInfoBottomSheet extends StatelessWidget {
final PlexFileInfo fileInfo;
@@ -39,7 +40,7 @@ class FileInfoBottomSheet extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
'File Info',
t.fileInfo.title,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
@@ -74,56 +75,56 @@ class FileInfoBottomSheet extends StatelessWidget {
],
// Video Section
_buildSectionHeader('Video'),
_buildSectionHeader(t.fileInfo.video),
const SizedBox(height: 8),
_buildInfoRow('Codec', fileInfo.videoCodec ?? 'Unknown'),
_buildInfoRow('Resolution', fileInfo.resolutionFormatted),
_buildInfoRow('Bitrate', fileInfo.bitrateFormatted),
_buildInfoRow('Frame Rate', fileInfo.frameRateFormatted),
_buildInfoRow('Aspect Ratio', fileInfo.aspectRatioFormatted),
_buildInfoRow(t.fileInfo.codec, fileInfo.videoCodec ?? t.common.unknown),
_buildInfoRow(t.fileInfo.resolution, fileInfo.resolutionFormatted),
_buildInfoRow(t.fileInfo.bitrate, fileInfo.bitrateFormatted),
_buildInfoRow(t.fileInfo.frameRate, fileInfo.frameRateFormatted),
_buildInfoRow(t.fileInfo.aspectRatio, fileInfo.aspectRatioFormatted),
if (fileInfo.videoProfile != null)
_buildInfoRow('Profile', fileInfo.videoProfile!),
_buildInfoRow(t.fileInfo.profile, fileInfo.videoProfile!),
if (fileInfo.bitDepth != null)
_buildInfoRow('Bit Depth', '${fileInfo.bitDepth} bit'),
_buildInfoRow(t.fileInfo.bitDepth, '${fileInfo.bitDepth} bit'),
if (fileInfo.colorSpace != null)
_buildInfoRow('Color Space', fileInfo.colorSpace!),
_buildInfoRow(t.fileInfo.colorSpace, fileInfo.colorSpace!),
if (fileInfo.colorRange != null)
_buildInfoRow('Color Range', fileInfo.colorRange!),
_buildInfoRow(t.fileInfo.colorRange, fileInfo.colorRange!),
if (fileInfo.colorPrimaries != null)
_buildInfoRow('Color Primaries', fileInfo.colorPrimaries!),
_buildInfoRow(t.fileInfo.colorPrimaries, fileInfo.colorPrimaries!),
if (fileInfo.chromaSubsampling != null)
_buildInfoRow('Chroma Subsampling', fileInfo.chromaSubsampling!),
_buildInfoRow(t.fileInfo.chromaSubsampling, fileInfo.chromaSubsampling!),
const SizedBox(height: 20),
// Audio Section
_buildSectionHeader('Audio'),
_buildSectionHeader(t.fileInfo.audio),
const SizedBox(height: 8),
_buildInfoRow('Codec', fileInfo.audioCodec ?? 'Unknown'),
_buildInfoRow('Channels', fileInfo.audioChannelsFormatted),
_buildInfoRow(t.fileInfo.codec, fileInfo.audioCodec ?? t.common.unknown),
_buildInfoRow(t.fileInfo.channels, fileInfo.audioChannelsFormatted),
if (fileInfo.audioProfile != null)
_buildInfoRow('Profile', fileInfo.audioProfile!),
_buildInfoRow(t.fileInfo.profile, fileInfo.audioProfile!),
const SizedBox(height: 20),
// File Section
_buildSectionHeader('File'),
_buildSectionHeader(t.fileInfo.file),
const SizedBox(height: 8),
if (fileInfo.filePath != null)
_buildInfoRow('Path', fileInfo.filePath!, isMonospace: true),
_buildInfoRow('Size', fileInfo.fileSizeFormatted),
_buildInfoRow('Container', fileInfo.container ?? 'Unknown'),
_buildInfoRow('Duration', fileInfo.durationFormatted),
_buildInfoRow(t.fileInfo.path, fileInfo.filePath!, isMonospace: true),
_buildInfoRow(t.fileInfo.size, fileInfo.fileSizeFormatted),
_buildInfoRow(t.fileInfo.container, fileInfo.container ?? t.common.unknown),
_buildInfoRow(t.fileInfo.duration, fileInfo.durationFormatted),
const SizedBox(height: 20),
// Advanced Section
_buildSectionHeader('Advanced'),
_buildSectionHeader(t.fileInfo.advanced),
const SizedBox(height: 8),
_buildInfoRow(
'Optimized for Streaming',
fileInfo.optimizedForStreaming == true ? 'Yes' : 'No',
t.fileInfo.optimizedForStreaming,
fileInfo.optimizedForStreaming == true ? t.common.yes : t.common.no,
),
_buildInfoRow(
'64-bit Offsets',
fileInfo.has64bitOffsets == true ? 'Yes' : 'No',
t.fileInfo.has64bitOffsets,
fileInfo.has64bitOffsets == true ? t.common.yes : t.common.no,
),
],
),
+5 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import '../i18n/strings.g.dart';
class HotKeyRecorderWidget extends StatefulWidget {
final String actionName;
@@ -31,7 +32,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Set Shortcut for ${widget.actionName}'),
title: Text(t.hotkeys.setShortcutFor(actionName: widget.actionName)),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
@@ -81,7 +82,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
minWidth: 24,
minHeight: 24,
),
tooltip: 'Clear shortcut',
tooltip: t.hotkeys.clearShortcut,
),
],
),
@@ -101,12 +102,12 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
),
),
actions: [
TextButton(onPressed: widget.onCancel, child: const Text('Cancel')),
TextButton(onPressed: widget.onCancel, child: Text(t.common.cancel)),
TextButton(
onPressed: _recordedHotKey != null
? () => widget.onHotKeyRecorded(_recordedHotKey!)
: null,
child: const Text('Save'),
child: Text(t.common.save),
),
],
);
+4 -3
View File
@@ -11,6 +11,7 @@ import '../utils/content_rating_formatter.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../theme/theme_helper.dart';
import '../i18n/strings.g.dart';
import 'media_context_menu.dart';
class MediaCard extends StatefulWidget {
@@ -44,9 +45,9 @@ class _MediaCardState extends State<MediaCard> {
if (itemType == 'artist' || itemType == 'album' || itemType == 'track') {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Music playback is not yet supported'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(t.messages.musicNotSupported),
duration: const Duration(seconds: 2),
),
);
}
+14 -13
View File
@@ -6,6 +6,7 @@ import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
import '../widgets/file_info_bottom_sheet.dart';
import '../utils/shuffle_play_helper.dart';
import '../i18n/strings.g.dart';
/// Helper class to store menu action data
class _MenuAction {
@@ -66,7 +67,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(
value: 'watch',
icon: Icons.check_circle_outline,
label: 'Mark as Watched',
label: t.mediaMenu.markAsWatched,
),
);
}
@@ -77,7 +78,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(
value: 'unwatch',
icon: Icons.remove_circle_outline,
label: 'Mark as Unwatched',
label: t.mediaMenu.markAsUnwatched,
),
);
}
@@ -86,7 +87,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
if ((itemType == 'episode' || itemType == 'season') &&
widget.metadata.grandparentTitle != null) {
menuActions.add(
_MenuAction(value: 'series', icon: Icons.tv, label: 'Go to series'),
_MenuAction(value: 'series', icon: Icons.tv, label: t.mediaMenu.goToSeries),
);
}
@@ -96,7 +97,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(
value: 'season',
icon: Icons.playlist_play,
label: 'Go to season',
label: t.mediaMenu.goToSeason,
),
);
}
@@ -107,7 +108,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(
value: 'shuffle_play',
icon: Icons.shuffle,
label: 'Shuffle Play',
label: t.mediaMenu.shufflePlay,
),
);
}
@@ -118,7 +119,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
_MenuAction(
value: 'fileinfo',
icon: Icons.info_outline,
label: 'File Info',
label: t.mediaMenu.fileInfo,
),
);
}
@@ -214,7 +215,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
await _executeAction(
context,
() => client.markAsWatched(widget.metadata.ratingKey),
'Marked as watched',
t.messages.markedAsWatched,
);
break;
@@ -222,7 +223,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
await _executeAction(
context,
() => client.markAsUnwatched(widget.metadata.ratingKey),
'Marked as unwatched',
t.messages.markedAsUnwatched,
);
break;
@@ -231,7 +232,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
context,
widget.metadata.grandparentRatingKey,
(metadata) => MediaDetailScreen(metadata: metadata),
'Error loading series',
t.messages.errorLoadingSeries,
);
break;
@@ -240,7 +241,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
context,
widget.metadata.parentRatingKey,
(metadata) => SeasonDetailScreen(season: metadata),
'Error loading season',
t.messages.errorLoadingSeason,
);
break;
@@ -272,7 +273,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
}
}
}
@@ -345,7 +346,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('File information not available')),
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
);
}
} catch (e) {
@@ -357,7 +358,7 @@ class _MediaContextMenuState extends State<MediaContextMenu> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error loading file info: $e')));
).showSnackBar(SnackBar(content: Text(t.messages.errorLoadingFileInfo(error: e.toString()))));
}
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../i18n/strings.g.dart';
/// Dialog for entering a PIN to access a protected profile
class PinEntryDialog extends StatefulWidget {
@@ -110,7 +111,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
LengthLimitingTextInputFormatter(10),
],
decoration: InputDecoration(
hintText: 'Enter PIN',
hintText: t.pinEntry.enterPin,
border: const OutlineInputBorder(),
errorText: widget.errorMessage,
errorMaxLines: 2,
@@ -124,7 +125,7 @@ class _PinEntryDialogState extends State<PinEntryDialog>
_obscureText = !_obscureText;
});
},
tooltip: _obscureText ? 'Show PIN' : 'Hide PIN',
tooltip: _obscureText ? t.pinEntry.showPin : t.pinEntry.hidePin,
),
),
onSubmitted: (_) => _submit(),
@@ -134,9 +135,9 @@ class _PinEntryDialogState extends State<PinEntryDialog>
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
FilledButton(onPressed: _submit, child: const Text('Submit')),
FilledButton(onPressed: _submit, child: Text(t.common.submit)),
],
),
);
+4 -3
View File
@@ -4,6 +4,7 @@ import '../models/plex_home_user.dart';
import '../providers/user_profile_provider.dart';
import '../utils/user_switching_utils.dart';
import 'profile_list_tile.dart';
import '../i18n/strings.g.dart';
class ProfileSwitchDialog extends StatelessWidget {
const ProfileSwitchDialog({super.key});
@@ -28,8 +29,8 @@ class ProfileSwitchDialog extends StatelessWidget {
child: Center(child: CircularProgressIndicator()),
)
else if (users.isEmpty)
const Expanded(
child: Center(child: Text('No users available')),
Expanded(
child: Center(child: Text(t.profile.noUsersAvailable)),
)
else
Expanded(
@@ -73,7 +74,7 @@ class ProfileSwitchDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
],
);
+6 -5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../services/plex_auth_service.dart';
import '../i18n/strings.g.dart';
class ServerListTile extends StatelessWidget {
final PlexServer server;
@@ -30,12 +31,12 @@ class ServerListTile extends StatelessWidget {
color: isOnline
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
semanticLabel: 'Server',
semanticLabel: t.common.server,
),
title: Text(server.name),
subtitle: Semantics(
label:
'${isOnline ? 'Online' : 'Offline'}, ${server.owned ? 'Owned' : 'Shared'}',
'${isOnline ? t.common.online : t.common.offline}, ${server.owned ? t.common.owned : t.common.shared}',
excludeSemantics: true,
child: Row(
children: [
@@ -49,7 +50,7 @@ class ServerListTile extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
isOnline ? 'Online' : 'Offline',
isOnline ? t.common.online : t.common.offline,
style: TextStyle(
fontSize: 12,
color: isOnline ? Colors.green : Colors.grey,
@@ -70,7 +71,7 @@ class ServerListTile extends StatelessWidget {
),
const SizedBox(width: 8),
Text(
server.owned ? 'Owned' : 'Shared',
server.owned ? t.common.owned : t.common.shared,
style: const TextStyle(fontSize: 12),
),
],
@@ -84,7 +85,7 @@ class ServerListTile extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
child: Text(
'CURRENT',
t.common.current,
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
+2 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../models/plex_sort.dart';
import '../i18n/strings.g.dart';
class SortBottomSheet extends StatefulWidget {
final List<PlexSort> sortOptions;
@@ -80,7 +81,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
if (widget.onClear != null)
TextButton(
onPressed: _handleClear,
child: const Text('Clear'),
child: Text(t.common.clear),
),
IconButton(
icon: const Icon(Icons.close),
+4 -3
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/plex_home_user.dart';
import '../i18n/strings.g.dart';
class UserAvatarWidget extends StatelessWidget {
final PlexHomeUser user;
@@ -48,7 +49,7 @@ class UserAvatarWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Admin',
t.userStatus.admin,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
@@ -67,7 +68,7 @@ class UserAvatarWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Restricted',
t.userStatus.restricted,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
@@ -86,7 +87,7 @@ class UserAvatarWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Protected',
t.userStatus.protected,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondary,
fontWeight: FontWeight.bold,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:plezy/services/settings_service.dart';
import '../../../i18n/strings.g.dart';
/// Bottom sheet for adjusting audio sync offset
class AudioSyncSheet extends StatefulWidget {
@@ -136,9 +137,9 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
// Slider
Row(
children: [
const Text(
'-2s',
style: TextStyle(color: Colors.white70),
Text(
t.videoControls.minusTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
Expanded(
child: Slider(
@@ -158,9 +159,9 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
},
),
),
const Text(
'+2s',
style: TextStyle(color: Colors.white70),
Text(
t.videoControls.addTime(amount: "2", unit: "s"),
style: const TextStyle(color: Colors.white70),
),
],
),
@@ -169,7 +170,7 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset to 0ms'),
label: Text(t.videoControls.resetToZero),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart';
/// Bottom sheet for selecting audio tracks
class AudioTrackSheet extends StatelessWidget {
@@ -53,9 +54,9 @@ class AudioTrackSheet extends StatelessWidget {
children: [
const Icon(Icons.audiotrack, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Audio Tracks',
style: TextStyle(
Text(
t.videoControls.audioLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../i18n/strings.g.dart';
/// Bottom sheet for sleep timer configuration
class SleepTimerSheet extends StatelessWidget {
@@ -135,7 +136,7 @@ class SleepTimerSheet extends StatelessWidget {
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add),
label: const Text('+15 min'),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(
@@ -151,7 +152,7 @@ class SleepTimerSheet extends StatelessWidget {
const SizedBox(width: 12),
FilledButton.icon(
icon: const Icon(Icons.cancel),
label: const Text('Cancel'),
label: Text(t.common.cancel),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
@@ -215,7 +216,7 @@ class SleepTimerSheet extends StatelessWidget {
// Show confirmation snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sleep timer set for $label'),
content: Text(t.messages.sleepTimerSet(label: label)),
duration: const Duration(seconds: 2),
),
);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart';
/// Bottom sheet for selecting subtitle tracks
class SubtitleTrackSheet extends StatelessWidget {
@@ -53,9 +54,9 @@ class SubtitleTrackSheet extends StatelessWidget {
children: [
const Icon(Icons.subtitles, color: Colors.white),
const SizedBox(width: 12),
const Text(
'Subtitles',
style: TextStyle(
Text(
t.videoControls.subtitlesLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -4,6 +4,7 @@ import '../../../services/settings_service.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/platform_detector.dart';
import '../widgets/sync_offset_control.dart';
import '../../../i18n/strings.g.dart';
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
@@ -431,7 +432,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add),
label: const Text('+15 min'),
label: Text(t.videoControls.addTime(amount: "15", unit: " min")),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
@@ -443,7 +444,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
const SizedBox(width: 12),
FilledButton.icon(
icon: const Icon(Icons.cancel),
label: const Text('Cancel'),
label: Text(t.common.cancel),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
@@ -493,7 +494,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
Navigator.pop(context); // Close after selection
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Sleep timer set for $label'),
content: Text(t.messages.sleepTimerSet(label: label)),
duration: const Duration(seconds: 2),
),
);
@@ -513,7 +514,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
player: widget.player,
propertyName: 'audio-delay',
initialOffset: _audioSyncOffset,
labelText: 'Audio',
labelText: t.videoControls.audioLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setAudioSyncOffset(offset);
@@ -529,7 +530,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
player: widget.player,
propertyName: 'sub-delay',
initialOffset: _subtitleSyncOffset,
labelText: 'Subtitles',
labelText: t.videoControls.subtitlesLabel,
onOffsetChanged: (offset) async {
final settings = await SettingsService.getInstance();
await settings.setSubtitleSyncOffset(offset);
@@ -18,6 +18,7 @@ import '../../services/sleep_timer_service.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/platform_detector.dart';
import '../../utils/provider_extensions.dart';
import '../../i18n/strings.g.dart';
import '../app_bar_back_button.dart';
import 'painters/chapter_marker_painter.dart';
import 'sheets/audio_track_sheet.dart';
@@ -394,13 +395,13 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
String _getBoxFitTooltip(int mode) {
switch (mode) {
case 0:
return 'Letterbox';
return t.videoControls.letterbox;
case 1:
return 'Fill screen';
return t.videoControls.fillScreen;
case 2:
return 'Stretch';
return t.videoControls.stretch;
default:
return 'Letterbox';
return t.videoControls.letterbox;
}
}
@@ -505,8 +506,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
? Icons.screen_lock_rotation
: Icons.screen_rotation,
tooltip: _isRotationLocked
? 'Unlock rotation'
: 'Lock rotation',
? t.videoControls.unlockRotation
: t.videoControls.lockRotation,
onPressed: _toggleRotationLock,
),
// Fullscreen toggle (desktop only)
@@ -1603,7 +1604,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error switching version: $e')));
).showSnackBar(SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))));
}
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart';
/// Reusable widget for adjusting sync offsets (audio or subtitle)
class SyncOffsetControl extends StatefulWidget {
@@ -69,11 +70,11 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
String _getDescriptionText() {
if (_currentOffset > 0) {
return '${widget.labelText} plays later';
return t.videoControls.playsLater(label: widget.labelText);
} else if (_currentOffset < 0) {
return '${widget.labelText} plays earlier';
return t.videoControls.playsEarlier(label: widget.labelText);
} else {
return 'No offset';
return t.videoControls.noOffset;
}
}
@@ -102,7 +103,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
// Slider
Row(
children: [
const Text('-2s', style: TextStyle(color: Colors.white70)),
Text(t.videoControls.minusTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
Expanded(
child: Slider(
value: _currentOffset,
@@ -121,7 +122,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
},
),
),
const Text('+2s', style: TextStyle(color: Colors.white70)),
Text(t.videoControls.addTime(amount: "2", unit: "s"), style: const TextStyle(color: Colors.white70)),
],
),
const SizedBox(height: 24),
@@ -129,7 +130,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
ElevatedButton.icon(
onPressed: _currentOffset != 0 ? _resetOffset : null,
icon: const Icon(Icons.restart_alt),
label: const Text('Reset to 0ms'),
label: Text(t.videoControls.resetToZero),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[800],
foregroundColor: Colors.white,
+98 -18
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "91.0.0"
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "8.4.1"
version: "7.7.1"
archive:
dependency: transitive
description:
@@ -53,18 +53,18 @@ packages:
dependency: transitive
description:
name: build
sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.1.2"
build_daemon:
dependency: transitive
description:
@@ -73,14 +73,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
@@ -185,14 +201,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csv:
dependency: transitive
description:
name: csv
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
url: "https://pub.dev"
source: hosted
version: "6.0.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
version: "3.1.1"
dbus:
dependency: transitive
description:
@@ -304,6 +328,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
@@ -400,6 +432,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json2yaml:
dependency: transitive
description:
name: json2yaml
sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51
url: "https://pub.dev"
source: hosted
version: "3.0.1"
json_annotation:
dependency: "direct main"
description:
@@ -412,10 +460,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
version: "6.11.1"
version: "6.9.5"
leak_tracker:
dependency: transitive
description:
@@ -909,22 +957,46 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
slang:
dependency: "direct main"
description:
name: slang
sha256: a466773de768eb95bdf681e0a92e7c8010d44bb247b62130426c83ece33aeaed
url: "https://pub.dev"
source: hosted
version: "3.32.0"
slang_build_runner:
dependency: "direct dev"
description:
name: slang_build_runner
sha256: b2e0c63f3c801a4aa70b4ca43173893d6eb7d5a421fc9d97ad983527397631b3
url: "https://pub.dev"
source: hosted
version: "3.32.0"
slang_flutter:
dependency: "direct main"
description:
name: slang_flutter
sha256: "1a98e878673996902fa5ef0b61ce5c245e41e4d25640d18af061c6aab917b0c7"
url: "https://pub.dev"
source: hosted
version: "3.32.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "9098ab86015c4f1d8af6486b547b11100e73b193e1899015033cb3e14ad20243"
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
version: "1.3.8"
version: "1.3.7"
source_span:
dependency: transitive
description:
@@ -1029,6 +1101,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.6"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
+3
View File
@@ -26,6 +26,8 @@ dependencies:
hotkey_manager: ^0.2.3
flex_color_picker: ^3.6.0
qr_flutter: ^4.1.0
slang: ^3.31.2
slang_flutter: ^3.31.0
os_media_controls:
git:
url: https://github.com/edde746/os-media-controls
@@ -50,6 +52,7 @@ dev_dependencies:
build_runner: ^2.4.7
json_serializable: ^6.7.1
flutter_launcher_icons: ^0.14.4
slang_build_runner: ^3.31.0
flutter:
uses-material-design: true