Compare commits

..
Author SHA1 Message Date
Your NameandClaude Opus 4.5 3708b24d55 feat(i18n): add Spanish language support with improved picker UI
- Add complete Spanish translations (app_es.arb)
- Replace cramped SegmentedButton with ListTile + Dialog picker
- Localize player states (Off, Idle, Unavailable, Selected)
- Localize Material You description
- Scales better for multiple languages
- Bump version to 2.3.2-beta+24

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:23 +00:00
Your NameandClaude Opus 4.5 e0b4a0a184 chore: bump version to 2.3.1-beta+23
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 08:05:19 +00:00
Your NameandClaude Opus 4.5 407ac9fa7c Complete German translations for remaining hardcoded strings
- Add 14 new localization keys: search, metadataApisDescription,
  lastFmApiKey, lastFmApiKeyHint, swipeToSwitchDevice, chapterNumber,
  pcmAudio, playOn, addAlbumToQueueOn, addToQueueOn, startRadioOn,
  book, audiobookSingular, trackCount
- Update settings_screen.dart: metadata API description, Last.fm fields
- Update global_player_overlay.dart: bottom navigation bar labels
- Update expandable_player.dart: unknown author, PCM audio, swipe hint
- Update album_details_screen.dart: play on/add to queue sheet titles
- Update audiobook_detail_screen.dart: play on sheet title
- Update artist_details_screen.dart: start radio on sheet title
- Update new_library_screen.dart: singular book/audiobook forms
- Update device_selector_bar.dart: swipe to switch device hint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:36:14 +00:00
Your NameandClaude Opus 4.5 493520ec1e Complete German translations for all remaining hardcoded strings
- Add 20+ new translation keys to app_en.arb and app_de.arb
- Update playlist_details_screen.dart: Tracks header, empty state
- Update audiobook_detail_screen.dart: Resume, About, Chapters, narrator, progress
- Update audiobook_series_screen.dart: No books in series, author comparison
- Update audiobook_author_screen.dart: Narrator display strings
- Update album_details_screen.dart: Tracks header, empty tracks state
- Update settings_screen.dart: API key labels, audiobook libraries section
- Update search_screen.dart: Artist label, no player selected
- Update queue_screen.dart: No upcoming tracks message
- Update new_library_screen.dart: Series empty state, playlist fallback
- Update library_playlists_screen.dart: Track count, playlist fallback
- Update debug_log_screen.dart: Bug report subject

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 07:04:10 +00:00
Your NameandClaude Opus 4.5 1ea2fa2cb6 fix(i18n): pass l10n parameter to _buildPodcastsComingSoonTab
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:34:05 +00:00
Your NameandClaude Opus 4.5 734ef3f8ed feat(i18n): add more German translations and fix picker styles
- Updated theme/language pickers to match library picker style (border radius 8, opacity 0.5)
- Added 35+ new translation keys for tooltips, settings, and UI elements
- Replaced hardcoded strings in settings, artist, album, audiobook screens
- Fixed sort/view mode tooltips across all detail screens

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 22:22:42 +00:00
Your NameandClaude Opus 4.5 2ffa0e1754 fix(i18n): add context to remaining DisconnectedState calls
Fixed DisconnectedState.full and DisconnectedState.withSettingsAction
calls in new_home_screen.dart and new_library_screen.dart.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:58:08 +00:00
Your NameandClaude Opus 4.5 c2e313dfb2 fix(i18n): add missing context parameter to EmptyState/DisconnectedState calls
Fixed all call sites that were missing the required context parameter
after the localization refactor.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:49:59 +00:00
Your NameandClaude Opus 4.5 cecea7c263 feat(i18n): localize all remaining screens and widgets
Localized files:
- screens: album_details, artist_details, audiobook_*, debug_log,
  home, library_*, new_library, playlist_details, queue
- widgets: disconnected_state, empty_state, expandable_player,
  chapters_panel

All hardcoded English strings replaced with S.of(context)! calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:36:51 +00:00
Your NameandClaude Opus 4.5 94085e8ed4 fix(settings): restore full-width segmented buttons for theme/language pickers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:18:14 +00:00
Your NameandClaude Opus 4.5 94101898d1 feat(i18n): add language picker and replace hardcoded strings
- Add LocaleProvider for managing app locale
- Add language picker in settings (System/EN/DE) matching library picker style
- Update theme picker to use same compact icon-bar style
- Replace hardcoded strings in login, settings, home, and search screens
- Persist language preference in SharedPreferences

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:04:31 +00:00
Your NameandClaude Opus 4.5 48bca80157 feat(i18n): add German language support infrastructure
- Add flutter_localizations and intl dependencies
- Create l10n.yaml configuration for code generation
- Add English (app_en.arb) and German (app_de.arb) translation files
- Configure MaterialApp with localization delegates
- Support 150+ translatable strings across the app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:41:26 +00:00
Your NameandClaude Opus 4.5 682cba577e fix: lazy load cached players for instant UI on app resume
- Make availablePlayers getter load from cache when empty
- Make selectedPlayer getter load from cache when null
- Update connecting banner to check hasCachedPlayers (cache service)
  instead of availablePlayers.isEmpty (timing-dependent)

This ensures mini player and device selector appear instantly when
resuming the app, without waiting for checkAndReconnect to run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:13:05 +00:00
Your NameandClaude Opus 4.5 f38d2cba4f feat: instant player display on app resume with caching
Cache players in memory so mini player and device selector appear
instantly when resuming the app, without waiting for reconnection.

- Add player caching to CacheService (players list + selected player)
- Load cached players immediately in checkAndReconnect before connecting
- Hide "Connecting..." banner when cached players are available
- Background reconnection happens silently while UI is already usable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:06:50 +00:00
Your NameandClaude Opus 4.5 20edbc2804 perf(player): skip builtin_player for MA 2.7.0b20+ servers
Instead of attempting 3 retries of builtin_player/register (which fail
on MA 2.7.0b20+) before falling back to Sendspin, detect the server
version upfront and go directly to Sendspin.

This saves ~2 seconds on app resume by avoiding:
- 3 failed builtin_player/register attempts
- Exponential backoff delays (500ms, 1s)
- Error handling overhead

The version check parses server_version (e.g., "2.8.0b2") and compares
against the 2.7.0b20 threshold where Sendspin was introduced.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:38:59 +00:00
Your NameandClaude Opus 4.5 e48b94e9c8 perf(player): optimize player loading on app resume
- Remove duplicate _preloadAdjacentPlayers call in checkAndReconnect
  (already called via refreshPlayers -> _loadAndSelectPlayers)
- Don't await preload in _loadAndSelectPlayers - UI should show
  immediately with cached data, preload happens in background

This fixes slow device button and mini player loading when returning
to the app, especially noticeable on slower network connections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:34:35 +00:00
36 changed files with 2736 additions and 457 deletions
+5
View File
@@ -0,0 +1,5 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false
output-class: S
+276
View File
@@ -0,0 +1,276 @@
{
"@@locale": "de",
"appTitle": "Ensemble",
"connecting": "Verbinde...",
"connected": "Verbunden",
"disconnected": "Getrennt",
"serverAddress": "Serveradresse",
"serverAddressHint": "z.B. music.example.com oder 192.168.1.100",
"yourName": "Dein Name",
"yourFirstName": "Dein Vorname",
"portOptional": "Port (Optional)",
"portHint": "z.B. 8095 oder leer lassen",
"portDescription": "Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.",
"username": "Benutzername",
"password": "Passwort",
"detectAndConnect": "Erkennen & Verbinden",
"connect": "Verbinden",
"disconnect": "Trennen",
"authServerUrlOptional": "Auth-Server URL (Optional)",
"authServerUrlHint": "z.B. auth.example.com (wenn abweichend vom Server)",
"authServerUrlDescription": "Leer lassen, wenn Authentifizierung auf demselben Server",
"detectedAuthType": "Erkannt: {authType}",
"serverRequiresAuth": "Dieser Server erfordert Authentifizierung",
"tailscaleVpnConnection": "Tailscale VPN-Verbindung",
"unencryptedConnection": "Unverschlusselte Verbindung",
"usingHttpOverTailscale": "HTTP uber Tailscale (verschlusselter Tunnel)",
"httpsFailedUsingHttp": "HTTPS fehlgeschlagen, verwende HTTP",
"httpNotEncrypted": "HTTP-Verbindung - Daten sind nicht verschlusselt",
"pleaseEnterServerAddress": "Bitte gib deine Music Assistant Serveradresse ein",
"pleaseEnterName": "Bitte gib deinen Namen ein",
"pleaseEnterValidPort": "Bitte gib eine gultige Portnummer ein (1-65535)",
"pleaseEnterCredentials": "Bitte gib Benutzername und Passwort ein",
"authFailed": "Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"maLoginFailed": "Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
"connectionFailed": "Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.",
"detectingAuth": "Erkenne Authentifizierung...",
"cannotDetermineAuth": "Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.",
"noAuthentication": "Keine Authentifizierung",
"httpBasicAuth": "HTTP Basic Auth",
"authelia": "Authelia",
"musicAssistantLogin": "Music Assistant Anmeldung",
"pressBackToMinimize": "Erneut drucken zum Minimieren",
"recentlyPlayed": "Zuletzt gespielt",
"discoverArtists": "Kunstler entdecken",
"discoverAlbums": "Alben entdecken",
"continueListening": "Weiterhoren",
"discoverAudiobooks": "Horbücher entdecken",
"discoverSeries": "Serien entdecken",
"favoriteAlbums": "Lieblingsalben",
"favoriteArtists": "Lieblingskünstler",
"favoriteTracks": "Lieblingssongs",
"searchMusic": "Musik suchen...",
"searchForContent": "Nach Künstlern, Alben oder Songs suchen",
"retry": "Wiederholen",
"all": "Alle",
"artists": "Künstler",
"albums": "Alben",
"tracks": "Songs",
"playlists": "Wiedergabelisten",
"audiobooks": "Hörbücher",
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
"queue": "Warteschlange",
"playerQueue": "{playerName} Warteschlange",
"noPlayerSelected": "Kein Player ausgewählt",
"undo": "Rückgängig",
"removedItem": "{itemName} entfernt",
"settings": "Einstellungen",
"debugLogs": "Debug-Protokolle",
"themeMode": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"pullToRefresh": "Ziehe zum Aktualisieren der Bibliothek",
"viewDebugLogs": "Debug-Protokolle anzeigen",
"viewAllPlayers": "Alle Player anzeigen",
"copyLogs": "Protokolle kopieren",
"clearLogs": "Protokolle löschen",
"copyList": "Liste kopieren",
"close": "Schließen",
"allPlayersCount": "Alle Player ({count})",
"errorLoadingPlayers": "Fehler beim Laden der Player: {error}",
"logsCopied": "Protokolle in Zwischenablage kopiert",
"logsCleared": "Protokolle gelöscht",
"playerListCopied": "Playerliste in Zwischenablage kopiert!",
"noLogsYet": "Noch keine Protokolle",
"infoPlus": "Info+",
"warnings": "Warnungen",
"errors": "Fehler",
"showingEntries": "Zeige {filtered} von {total} Einträgen",
"thisDevice": "Dieses Gerät",
"ghostPlayer": "Geist-Player (Duplikat)",
"unavailableCorrupt": "Nicht verfügbar/Beschädigt",
"playerId": "ID: {id}",
"playerInfo": "Verfügbar: {available} | Anbieter: {provider}",
"shareBugReport": "Fehlerbericht teilen",
"moreOptions": "Weitere Optionen",
"failedToUpdateFavorite": "Favorit konnte nicht aktualisiert werden: {error}",
"noPlayersAvailable": "Keine Player verfügbar",
"albumAddedToQueue": "Album zur Warteschlange hinzugefügt",
"tracksAddedToQueue": "Songs zur Warteschlange hinzugefügt",
"play": "Abspielen",
"noPlayersFound": "Keine Player gefunden",
"startingRadio": "Starte {name} Radio",
"failedToStartRadio": "Radio konnte nicht gestartet werden: {error}",
"startingRadioOnPlayer": "Starte {name} Radio auf {playerName}",
"addedRadioToQueue": "{name} Radio zur Warteschlange hinzugefügt",
"failedToAddToQueue": "Konnte nicht zur Warteschlange hinzufügen: {error}",
"radio": "Radio",
"inLibrary": "In Bibliothek",
"noAlbumsFound": "Keine Alben gefunden",
"playing": "Spiele {name}",
"playingTrack": "Spiele Song",
"markedAsFinished": "{name} als beendet markiert",
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
"markedAsUnplayed": "{name} als ungespielt markiert",
"failedToMarkUnplayed": "Konnte nicht als ungespielt markieren: {error}",
"failedToPlay": "Abspielen fehlgeschlagen: {error}",
"markAsFinished": "Als beendet markieren",
"markAsUnplayed": "Als ungespielt markieren",
"byAuthor": "Von {author}",
"audiobookCount": "{count} Hörbuch/Hörbücher",
"loading": "Laden...",
"bookCount": "{count} Buch/Bücher",
"books": "Bücher",
"noFavoriteAudiobooks": "Keine Lieblingshörbücher",
"tapHeartAudiobook": "Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen",
"noAudiobooks": "Keine Hörbücher",
"addAudiobooksHint": "Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen",
"noFavoriteArtists": "Keine Lieblingskünstler",
"tapHeartArtist": "Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen",
"noFavoriteAlbums": "Keine Lieblingsalben",
"tapHeartAlbum": "Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen",
"noFavoritePlaylists": "Keine Lieblings-Wiedergabelisten",
"tapHeartPlaylist": "Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen",
"noFavoriteTracks": "Keine Lieblingssongs",
"longPressTrackHint": "Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen",
"loadSeries": "Serien laden",
"notConnected": "Nicht mit Music Assistant verbunden",
"notConnectedTitle": "Nicht verbunden",
"connectHint": "Verbinde dich mit deinem Music Assistant Server, um Musik zu hören",
"configureServer": "Server konfigurieren",
"noArtistsFound": "Keine Künstler gefunden",
"noTracksFound": "Keine Songs gefunden",
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
"queueIsEmpty": "Warteschlange ist leer",
"noResultsFound": "Keine Ergebnisse gefunden",
"refresh": "Aktualisieren",
"debugConsole": "Debug-Konsole",
"copy": "Kopieren",
"clear": "Löschen",
"noLogsToCopy": "Keine Protokolle zum Kopieren",
"noDebugLogsYet": "Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.",
"showDebug": "Debug anzeigen",
"hideDebug": "Debug ausblenden",
"chapters": "Kapitel",
"noChapters": "Keine Kapitel",
"noChapterInfo": "Dieses Hörbuch hat keine Kapitelinformationen",
"errorSeeking": "Fehler beim Spulen: {error}",
"error": "Fehler: {error}",
"home": "Start",
"library": "Bibliothek",
"homeScreen": "Startbildschirm",
"metadataApis": "Metadaten-APIs",
"theme": "Design",
"materialYou": "Material You",
"adaptiveTheme": "Adaptives Design",
"language": "Sprache",
"english": "Englisch",
"german": "Deutsch",
"spanish": "Spanisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren",
"sortByYear": "Nach Jahr sortieren",
"sortBySeriesOrder": "Nach Serienreihenfolge sortieren",
"listView": "Listenansicht",
"gridView": "Rasteransicht",
"noBooksInSeries": "Keine Bücher in dieser Serie gefunden",
"artist": "Künstler",
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
"showFavoriteAlbums": "Lieblingsalben anzeigen",
"showFavoriteArtists": "Lieblingskünstler anzeigen",
"showFavoriteTracks": "Lieblingssongs anzeigen",
"extractColorsFromArtwork": "Farben aus Album- und Künstlercover extrahieren",
"chooseHomeScreenRows": "Wähle welche Zeilen auf dem Startbildschirm angezeigt werden",
"addedToFavorites": "Zu Favoriten hinzugefügt",
"removedFromFavorites": "Aus Favoriten entfernt",
"unknown": "Unbekannt",
"noUpcomingTracks": "Keine weiteren Songs",
"showAll": "Alle anzeigen",
"showFavoritesOnly": "Nur Favoriten anzeigen",
"changeView": "Ansicht ändern",
"authors": "Autoren",
"series": "Serien",
"shows": "Sendungen",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Podcast-Unterstützung kommt bald",
"playlist": "Wiedergabeliste",
"connectionError": "Verbindungsfehler",
"twoColumnGrid": "2-Spalten-Raster",
"threeColumnGrid": "3-Spalten-Raster",
"fromProviders": "Von Anbietern",
"resume": "Fortsetzen",
"about": "Über",
"inProgress": "In Bearbeitung",
"narratedBy": "Gesprochen von {narrators}",
"unknownNarrator": "Unbekannter Sprecher",
"unknownAuthor": "Unbekannter Autor",
"loadingChapters": "Lade Kapitel...",
"noChapterInfoAvailable": "Keine Kapitelinformationen verfügbar",
"percentComplete": "{percent}% abgeschlossen",
"theAudioDbApiKey": "TheAudioDB API-Schlüssel",
"theAudioDbApiKeyHint": "\"2\" für kostenlosen Zugang oder Premium-Schlüssel",
"audiobookLibraries": "Hörbuch-Bibliotheken",
"chooseAudiobookLibraries": "Wähle welche Audiobookshelf-Bibliotheken einbezogen werden",
"unknownLibrary": "Unbekannte Bibliothek",
"noSeriesFound": "Keine Serien gefunden",
"ensembleBugReport": "Ensemble Fehlerbericht",
"byOwner": "Von {owner}",
"noSeriesAvailable": "Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.",
"pullToLoadSeries": "Ziehe nach unten um Serien\nvon Music Assistant zu laden",
"search": "Suche",
"metadataApisDescription": "Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.",
"lastFmApiKey": "Last.fm API-Schlüssel",
"lastFmApiKeyHint": "Kostenlosen Schlüssel auf last.fm/api holen",
"swipeToSwitchDevice": "Wischen um Gerät zu wechseln",
"chapterNumber": "Kapitel {number}",
"pcmAudio": "PCM-Audio",
"playOn": "Abspielen auf...",
"addAlbumToQueueOn": "Album zur Warteschlange hinzufügen auf...",
"addToQueueOn": "Zur Warteschlange hinzufügen auf...",
"startRadioOn": "{name} Radio starten auf...",
"book": "Buch",
"audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt"
}
+1165
View File
File diff suppressed because it is too large Load Diff
+472
View File
@@ -0,0 +1,472 @@
{
"@@locale": "es",
"appTitle": "Ensemble",
"connecting": "Conectando...",
"connected": "Conectado",
"disconnected": "Desconectado",
"serverAddress": "Dirección del servidor",
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
"yourName": "Tu nombre",
"yourFirstName": "Tu nombre de pila",
"portOptional": "Puerto (Opcional)",
"portHint": "ej., 8095 o dejar en blanco",
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
"username": "Usuario",
"password": "Contraseña",
"detectAndConnect": "Detectar y conectar",
"connect": "Conectar",
"disconnect": "Desconectar",
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
"detectedAuthType": "Detectado: {authType}",
"@detectedAuthType": {
"placeholders": {
"authType": {
"type": "String"
}
}
},
"serverRequiresAuth": "Este servidor requiere autenticación",
"tailscaleVpnConnection": "Conexión VPN Tailscale",
"unencryptedConnection": "Conexión sin cifrar",
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
"pleaseEnterName": "Por favor, introduce tu nombre",
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
"detectingAuth": "Detectando autenticación...",
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
"noAuthentication": "Sin autenticación",
"httpBasicAuth": "Autenticación HTTP Basic",
"authelia": "Authelia",
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
"favoriteAlbums": "Álbumes favoritos",
"favoriteArtists": "Artistas favoritos",
"favoriteTracks": "Canciones favoritas",
"searchMusic": "Buscar música...",
"searchForContent": "Buscar artistas, álbumes o canciones",
"retry": "Reintentar",
"all": "Todo",
"artists": "Artistas",
"albums": "Álbumes",
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
"@playerQueue": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"noPlayerSelected": "Ningún reproductor seleccionado",
"undo": "Deshacer",
"removedItem": "Eliminado {itemName}",
"@removedItem": {
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"settings": "Ajustes",
"debugLogs": "Registros de depuración",
"themeMode": "Modo de tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
"viewDebugLogs": "Ver registros de depuración",
"viewAllPlayers": "Ver todos los reproductores",
"copyLogs": "Copiar registros",
"clearLogs": "Borrar registros",
"copyList": "Copiar lista",
"close": "Cerrar",
"allPlayersCount": "Todos los reproductores ({count})",
"@allPlayersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
"@errorLoadingPlayers": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"logsCopied": "Registros copiados al portapapeles",
"logsCleared": "Registros borrados",
"playerListCopied": "Lista de reproductores copiada al portapapeles",
"noLogsYet": "Aún no hay registros",
"infoPlus": "Info+",
"warnings": "Advertencias",
"errors": "Errores",
"showingEntries": "Mostrando {filtered} de {total} entradas",
"@showingEntries": {
"placeholders": {
"filtered": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"thisDevice": "Este dispositivo",
"ghostPlayer": "Reproductor fantasma (duplicado)",
"unavailableCorrupt": "No disponible/Corrupto",
"playerId": "ID: {id}",
"@playerId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
"@playerInfo": {
"placeholders": {
"available": {
"type": "String"
},
"provider": {
"type": "String"
}
}
},
"shareBugReport": "Compartir informe de errores",
"moreOptions": "Más opciones",
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
"@failedToUpdateFavorite": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noPlayersAvailable": "No hay reproductores disponibles",
"albumAddedToQueue": "Álbum añadido a la cola",
"tracksAddedToQueue": "Canciones añadidas a la cola",
"play": "Reproducir",
"noPlayersFound": "No se encontraron reproductores",
"startingRadio": "Iniciando radio de {name}",
"@startingRadio": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToStartRadio": "Error al iniciar radio: {error}",
"@failedToStartRadio": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
"@startingRadioOnPlayer": {
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"addedRadioToQueue": "Radio de {name} añadida a la cola",
"@addedRadioToQueue": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToAddToQueue": "Error al añadir a la cola: {error}",
"@failedToAddToQueue": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"radio": "Radio",
"inLibrary": "En la biblioteca",
"noAlbumsFound": "No se encontraron álbumes",
"playing": "Reproduciendo {name}",
"@playing": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"playingTrack": "Reproduciendo canción",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkFinished": "Error al marcar como terminado: {error}",
"@failedToMarkFinished": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markedAsUnplayed": "{name} marcado como no reproducido",
"@markedAsUnplayed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
"@failedToMarkUnplayed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlay": "Error al reproducir: {error}",
"@failedToPlay": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markAsFinished": "Marcar como terminado",
"markAsUnplayed": "Marcar como no reproducido",
"byAuthor": "Por {author}",
"@byAuthor": {
"placeholders": {
"author": {
"type": "String"
}
}
},
"audiobookCount": "{count} audiolibro(s)",
"@audiobookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"loading": "Cargando...",
"bookCount": "{count} libro(s)",
"@bookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"books": "Libros",
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
"noAudiobooks": "No hay audiolibros",
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
"noFavoriteArtists": "No hay artistas favoritos",
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
"noFavoriteAlbums": "No hay álbumes favoritos",
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
"noFavoriteTracks": "No hay canciones favoritas",
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
"loadSeries": "Cargar series",
"notConnected": "No conectado a Music Assistant",
"notConnectedTitle": "No conectado",
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
"configureServer": "Configurar servidor",
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
"debugConsole": "Consola de depuración",
"copy": "Copiar",
"clear": "Borrar",
"noLogsToCopy": "No hay registros para copiar",
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
"showDebug": "Mostrar depuración",
"hideDebug": "Ocultar depuración",
"chapters": "Capítulos",
"noChapters": "Sin capítulos",
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
"errorSeeking": "Error al buscar: {error}",
"@errorSeeking": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"error": "Error: {error}",
"@error": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"home": "Inicio",
"library": "Biblioteca",
"homeScreen": "Pantalla de inicio",
"metadataApis": "APIs de metadatos",
"theme": "Tema",
"materialYou": "Material You",
"adaptiveTheme": "Tema adaptativo",
"language": "Idioma",
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
"sortBySeriesOrder": "Ordenar por orden de serie",
"listView": "Vista de lista",
"gridView": "Vista de cuadrícula",
"noBooksInSeries": "No se encontraron libros en esta serie",
"artist": "Artista",
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
"addedToFavorites": "Añadido a favoritos",
"removedFromFavorites": "Eliminado de favoritos",
"unknown": "Desconocido",
"noUpcomingTracks": "No hay canciones próximas",
"showAll": "Mostrar todo",
"showFavoritesOnly": "Mostrar solo favoritos",
"changeView": "Cambiar vista",
"authors": "Autores",
"series": "Series",
"shows": "Programas",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
"playlist": "Lista de reproducción",
"connectionError": "Error de conexión",
"twoColumnGrid": "Cuadrícula de 2 columnas",
"threeColumnGrid": "Cuadrícula de 3 columnas",
"fromProviders": "De proveedores",
"resume": "Continuar",
"about": "Acerca de",
"inProgress": "En progreso",
"narratedBy": "Narrado por {narrators}",
"@narratedBy": {
"placeholders": {
"narrators": {
"type": "String"
}
}
},
"unknownNarrator": "Narrador desconocido",
"unknownAuthor": "Autor desconocido",
"loadingChapters": "Cargando capítulos...",
"noChapterInfoAvailable": "No hay información de capítulos disponible",
"percentComplete": "{percent}% completado",
"@percentComplete": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"theAudioDbApiKey": "Clave API de TheAudioDB",
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
"audiobookLibraries": "Bibliotecas de audiolibros",
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
"unknownLibrary": "Biblioteca desconocida",
"noSeriesFound": "No se encontraron series",
"ensembleBugReport": "Informe de errores de Ensemble",
"byOwner": "Por {owner}",
"@byOwner": {
"placeholders": {
"owner": {
"type": "String"
}
}
},
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
"search": "Buscar",
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"pcmAudio": "Audio PCM",
"playOn": "Reproducir en...",
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
"addToQueueOn": "Añadir a la cola en...",
"startRadioOn": "Iniciar radio de {name} en...",
"@startRadioOn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"book": "libro",
"audiobookSingular": "audiolibro",
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
"@trackCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado"
}
+17 -2
View File
@@ -3,6 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'providers/locale_provider.dart';
import 'providers/music_assistant_provider.dart';
import 'providers/navigation_provider.dart';
import 'screens/home_screen.dart';
@@ -87,6 +90,7 @@ class MusicAssistantApp extends StatefulWidget {
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
late MusicAssistantProvider _musicProvider;
late ThemeProvider _themeProvider;
late LocaleProvider _localeProvider;
final _hardwareVolumeService = HardwareVolumeService();
StreamSubscription? _volumeUpSub;
StreamSubscription? _volumeDownSub;
@@ -101,6 +105,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
super.initState();
_musicProvider = MusicAssistantProvider();
_themeProvider = ThemeProvider();
_localeProvider = LocaleProvider();
WidgetsBinding.instance.addObserver(this);
_initHardwareVolumeControl();
// Listen to player selection changes to toggle volume interception
@@ -197,9 +202,10 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
providers: [
ChangeNotifierProvider.value(value: _musicProvider),
ChangeNotifierProvider.value(value: _themeProvider),
ChangeNotifierProvider.value(value: _localeProvider),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
child: Consumer2<ThemeProvider, LocaleProvider>(
builder: (context, themeProvider, localeProvider, _) {
return FutureBuilder<(ColorScheme, ColorScheme)?>(
future: themeProvider.useMaterialTheme
? SystemThemeHelper.getSystemColorSchemes()
@@ -228,6 +234,15 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
navigatorKey: navigationProvider.navigatorKey,
title: 'Ensemble',
debugShowCheckedModeBanner: false,
// Localization
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: S.supportedLocales,
locale: localeProvider.locale,
themeMode: themeProvider.themeMode,
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import '../services/settings_service.dart';
class LocaleProvider extends ChangeNotifier {
Locale? _locale; // null = system default
LocaleProvider() {
_loadLocale();
}
Locale? get locale => _locale;
Future<void> _loadLocale() async {
final savedLocale = await SettingsService.getLocale();
if (savedLocale != null) {
_locale = Locale(savedLocale);
}
notifyListeners();
}
Future<void> setLocale(Locale? locale) async {
_locale = locale;
await SettingsService.saveLocale(locale?.languageCode);
notifyListeners();
}
/// Returns the display name for a locale
static String getDisplayName(String? languageCode, BuildContext context) {
switch (languageCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case null:
return 'System';
default:
return languageCode;
}
}
}
+105 -7
View File
@@ -109,10 +109,28 @@ class MusicAssistantProvider with ChangeNotifier {
/// Current sync status
SyncStatus get syncStatus => SyncService.instance.status;
Player? get selectedPlayer => _selectedPlayer;
List<Player> get availablePlayers => _availablePlayers;
/// Selected player - loads from cache if not yet set
Player? get selectedPlayer {
if (_selectedPlayer == null && _cacheService.getCachedSelectedPlayer() != null) {
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
}
return _selectedPlayer;
}
/// Available players - loads from cache for instant UI display
List<Player> get availablePlayers {
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_logger.log('⚡ Loaded ${_availablePlayers.length} players from cache (lazy)');
}
return _availablePlayers;
}
Track? get currentTrack => _currentTrack;
/// Whether we have cached players available (for instant UI display on app resume)
bool get hasCachedPlayers => _cacheService.hasCachedPlayers;
/// Currently playing audiobook context (with chapters) - set when playing an audiobook
Audiobook? get currentAudiobook => _currentAudiobook;
@@ -402,6 +420,30 @@ class MusicAssistantProvider with ChangeNotifier {
return;
}
// IMMEDIATELY load cached players for instant UI display
// This makes mini player and device button appear instantly on app resume
if (_availablePlayers.isEmpty && _cacheService.hasCachedPlayers) {
_availablePlayers = _cacheService.getCachedPlayers()!;
_selectedPlayer = _cacheService.getCachedSelectedPlayer();
// Also try to restore from settings if cache doesn't have selected player
if (_selectedPlayer == null && _availablePlayers.isNotEmpty) {
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
if (lastSelectedPlayerId != null) {
try {
_selectedPlayer = _availablePlayers.firstWhere(
(p) => p.playerId == lastSelectedPlayerId,
);
} catch (e) {
_selectedPlayer = _availablePlayers.first;
}
} else {
_selectedPlayer = _availablePlayers.first;
}
}
_logger.log('⚡ Loaded ${_availablePlayers.length} cached players instantly');
notifyListeners(); // Update UI immediately with cached data
}
if (_connectionState != MAConnectionState.connected &&
_connectionState != MAConnectionState.authenticated) {
_logger.log('🔄 Not connected, attempting reconnect to $_serverUrl');
@@ -416,8 +458,7 @@ class MusicAssistantProvider with ChangeNotifier {
try {
await refreshPlayers();
await _updatePlayerState();
// Preload all player tracks to ensure fresh data after app resume
await _preloadAdjacentPlayers(preloadAll: true);
// Note: _preloadAdjacentPlayers is already called in refreshPlayers() -> _loadAndSelectPlayers()
_logger.log('🔄 Connection verified, players and state refreshed');
} catch (e) {
_logger.log('🔄 Connection verification failed, reconnecting: $e');
@@ -488,6 +529,39 @@ class MusicAssistantProvider with ChangeNotifier {
}
}
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
bool _serverUsesSendspin() {
final serverInfo = _api?.serverInfo;
if (serverInfo == null) return false;
final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return false;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr);
if (match == null) return false;
final major = int.parse(match.group(1)!);
final minor = int.parse(match.group(2)!);
final patch = int.parse(match.group(3)!);
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null;
// Compare with 2.7.0b20
if (major > 2) return true;
if (major < 2) return false;
// major == 2
if (minor > 7) return true;
if (minor < 7) return false;
// minor == 7
if (patch > 0) return true;
if (patch < 0) return false;
// patch == 0, so version is 2.7.0 - need beta >= 20
if (beta == null) return true; // 2.7.0 release is newer than 2.7.0b20
return beta >= 20;
}
Future<void> _registerLocalPlayer() async {
if (_api == null) return;
@@ -506,6 +580,26 @@ class MusicAssistantProvider with ChangeNotifier {
final name = await SettingsService.getLocalPlayerName();
// Check if server uses Sendspin (MA 2.7.0b20+) - skip builtin_player entirely
if (_serverUsesSendspin()) {
_logger.log('📡 Server uses Sendspin (MA 2.7.0b20+), skipping builtin_player');
_builtinPlayerAvailable = false;
final sendspinSuccess = await _connectViaSendspin();
if (sendspinSuccess) {
_logger.log('✅ Connected via Sendspin - local player available');
_startReportingLocalPlayerState();
} else {
_logger.log('⚠️ Sendspin connection failed - local player unavailable');
}
if (_registrationInProgress != null && !_registrationInProgress!.isCompleted) {
_registrationInProgress!.complete();
}
_registrationInProgress = null;
return;
}
final existingPlayers = await _api!.getPlayers();
final existingPlayer = existingPlayers.where((p) => p.playerId == playerId).firstOrNull;
@@ -1612,7 +1706,8 @@ class MusicAssistantProvider with ChangeNotifier {
_availablePlayers.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_cacheService.updatePlayersLastFetched();
// Cache players for instant display on app resume
_cacheService.setCachedPlayers(_availablePlayers);
_logger.log('🎛️ After filtering: ${_availablePlayers.length} players available');
@@ -1670,8 +1765,9 @@ class MusicAssistantProvider with ChangeNotifier {
selectPlayer(playerToSelect);
}
// Await preload so track data and images are ready for swipe gestures
await _preloadAdjacentPlayers(preloadAll: true);
// Preload track data and images in background for swipe gestures
// Don't await - UI should show immediately with cached data
unawaited(_preloadAdjacentPlayers(preloadAll: true));
} catch (e) {
ErrorHandler.logError('Load and select players', e);
}
@@ -1680,6 +1776,8 @@ class MusicAssistantProvider with ChangeNotifier {
void selectPlayer(Player player, {bool skipNotify = false}) async {
_selectedPlayer = player;
// Cache for instant display on app resume
_cacheService.setCachedSelectedPlayer(player);
SettingsService.setLastSelectedPlayerId(player.playerId);
// Immediately set currentTrack from cache to avoid flash during player switch
+29 -28
View File
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
import 'artist_details_screen.dart';
class AlbumDetailsScreen extends StatefulWidget {
@@ -17,7 +18,7 @@ class AlbumDetailsScreen extends StatefulWidget {
final String? heroTagSuffix;
const AlbumDetailsScreen({
super.key,
super.key,
required this.album,
this.heroTagSuffix,
});
@@ -36,7 +37,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
int? _expandedTrackIndex;
bool _isDescriptionExpanded = false;
String? _albumDescription;
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
@override
@@ -171,7 +172,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
),
duration: const Duration(seconds: 1),
),
@@ -182,7 +183,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -257,7 +258,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
),
duration: const Duration(seconds: 1),
),
@@ -268,7 +269,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -300,7 +301,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -326,7 +327,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -362,14 +363,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Add album to queue on...',
S.of(context)!.addAlbumToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -397,9 +398,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
_logger.log('Album added to queue on ${player.name}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Album added to queue'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.albumAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
@@ -442,14 +443,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Add to queue on...',
S.of(context)!.addToQueueOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -476,9 +477,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tracks added to queue'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.tracksAddedToQueue),
duration: const Duration(seconds: 1),
),
);
}
@@ -763,7 +764,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
child: ElevatedButton.icon(
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -837,7 +838,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
),
const SizedBox(height: 24),
Text(
'Tracks',
S.of(context)!.tracks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -858,7 +859,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
SliverFillRemaining(
child: Center(
child: Text(
'No tracks found',
S.of(context)!.noTracksFound,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
@@ -1089,14 +1090,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
children: [
const SizedBox(height: 16),
Text(
'Play on...',
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? const Center(
child: Text('No players available'),
? Center(
child: Text(S.of(context)!.noPlayersAvailable),
)
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
+28 -27
View File
@@ -12,6 +12,7 @@ import '../services/metadata_service.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../l10n/app_localizations.dart';
class ArtistDetailsScreen extends StatefulWidget {
final Artist artist;
@@ -211,7 +212,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
),
duration: const Duration(seconds: 1),
),
@@ -222,7 +223,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -236,7 +237,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
@@ -246,7 +247,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio'),
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
@@ -256,7 +257,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -282,14 +283,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start ${widget.artist.name} radio on...',
S.of(context)!.startRadioOn(widget.artist.name),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (players.isEmpty)
const Padding(
padding: EdgeInsets.all(32.0),
child: Text('No players available'),
Padding(
padding: const EdgeInsets.all(32.0),
child: Text(S.of(context)!.noPlayersAvailable),
)
else
Flexible(
@@ -311,7 +312,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
duration: const Duration(seconds: 1),
),
);
@@ -321,7 +322,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start radio: $e'),
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -345,7 +346,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
@@ -356,7 +357,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${widget.artist.name} radio to queue'),
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
duration: const Duration(seconds: 1),
),
);
@@ -366,7 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add to queue: $e'),
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -442,7 +443,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
});
}
}
@override
Widget build(BuildContext context) {
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
@@ -583,7 +584,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: ElevatedButton.icon(
onPressed: _startArtistRadio,
icon: const Icon(Icons.radio),
label: const Text('Radio'),
label: Text(S.of(context)!.radio),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -669,7 +670,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
SliverFillRemaining(
child: Center(
child: Text(
'No albums found',
S.of(context)!.noAlbumsFound,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.54),
fontSize: 16,
@@ -686,7 +687,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Row(
children: [
Text(
'In Library',
S.of(context)!.inLibrary,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -700,7 +701,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -718,10 +719,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
size: 20,
),
tooltip: _viewMode == 'grid2'
? '3-column grid'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -742,7 +743,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
child: Row(
children: [
Text(
'From Providers',
S.of(context)!.fromProviders,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -757,7 +758,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -774,10 +775,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
size: 20,
),
tooltip: _viewMode == 'grid2'
? '3-column grid'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
+11 -10
View File
@@ -12,6 +12,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart';
class AudiobookAuthorScreen extends StatefulWidget {
@@ -218,7 +219,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
const SizedBox(height: 8),
Text(
'${_audiobooks.length} ${_audiobooks.length == 1 ? 'audiobook' : 'audiobooks'}',
S.of(context)!.audiobookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
@@ -234,7 +235,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
child: Row(
children: [
Text(
'Audiobooks',
S.of(context)!.audiobooks,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@@ -248,7 +249,7 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -266,10 +267,10 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
size: 20,
),
tooltip: _viewMode == 'grid2'
? '3-column grid'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -405,9 +406,9 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
),
if (book.narratorsString != 'Unknown Narrator')
if (book.narratorsString != S.of(context)!.unknownNarrator)
Text(
'Narrated by ${book.narratorsString}',
S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
@@ -488,8 +489,8 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
),
),
subtitle: Text(
book.narratorsString != 'Unknown Narrator'
? 'Narrated by ${book.narratorsString}'
book.narratorsString != S.of(context)!.unknownNarrator
? S.of(context)!.narratedBy(book.narratorsString)
: book.duration != null
? _formatDuration(book.duration!)
: '',
+25 -24
View File
@@ -10,6 +10,7 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
class AudiobookDetailScreen extends StatefulWidget {
final Audiobook audiobook;
@@ -172,7 +173,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites,
),
duration: const Duration(seconds: 1),
),
@@ -183,7 +184,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -215,13 +216,13 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
const SizedBox(height: 16),
Text(
'Play on...',
S.of(context)!.playOn,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: players.isEmpty
? const Center(child: Text('No players available'))
? Center(child: Text(S.of(context)!.noPlayersAvailable))
: ListView.builder(
padding: EdgeInsets.only(bottom: bottomPadding + 16),
itemCount: players.length,
@@ -287,7 +288,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${_audiobook.name} marked as finished'),
content: Text(S.of(context)!.markedAsFinished(_audiobook.name)),
duration: const Duration(seconds: 2),
),
);
@@ -297,7 +298,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to mark as finished: $e'),
content: Text(S.of(context)!.failedToMarkFinished(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -338,7 +339,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${_audiobook.name} marked as unplayed'),
content: Text(S.of(context)!.markedAsUnplayed(_audiobook.name)),
duration: const Duration(seconds: 2),
),
);
@@ -348,7 +349,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to mark as unplayed: $e'),
content: Text(S.of(context)!.failedToMarkUnplayed(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -362,7 +363,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
@@ -392,7 +393,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${widget.audiobook.name}'),
content: Text(S.of(context)!.playing(widget.audiobook.name)),
duration: const Duration(seconds: 1),
),
);
@@ -402,7 +403,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play: $e'),
content: Text(S.of(context)!.failedToPlay(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -489,7 +490,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
size: 20,
),
const SizedBox(width: 12),
const Text('Mark as Finished'),
Text(S.of(context)!.markAsFinished),
],
),
),
@@ -503,7 +504,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
size: 20,
),
const SizedBox(width: 12),
const Text('Mark as Unplayed'),
Text(S.of(context)!.markAsUnplayed),
],
),
),
@@ -580,17 +581,17 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Author
Text(
'By ${book.authorsString}',
S.of(context)!.byAuthor(book.authorsString),
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.8),
),
),
// Narrator
if (book.narratorsString != 'Unknown Narrator') ...[
if (book.narratorsString != S.of(context)!.unknownNarrator) ...[
const SizedBox(height: 4),
Text(
'Narrated by ${book.narratorsString}',
S.of(context)!.narratedBy(book.narratorsString),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
@@ -624,7 +625,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
const SizedBox(width: 4),
Text(
'${(book.progress * 100).toInt()}% complete',
S.of(context)!.percentComplete((book.progress * 100).toInt()),
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.primary,
),
@@ -662,7 +663,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
startPositionMs: hasResumePosition ? book.resumePositionMs : null,
),
icon: Icon(hasResumePosition ? Icons.play_arrow : Icons.play_arrow),
label: Text(hasResumePosition ? 'Resume' : 'Play'),
label: Text(hasResumePosition ? S.of(context)!.resume : S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
@@ -740,7 +741,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
if (book.description != null && book.description!.isNotEmpty) ...[
const SizedBox(height: 24),
Text(
'About',
S.of(context)!.about,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@@ -771,7 +772,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
// Chapters Header
const SizedBox(height: 24),
Text(
'Chapters',
S.of(context)!.chapters,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@@ -815,7 +816,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
const SizedBox(height: 12),
Text(
'Loading chapters...',
S.of(context)!.loadingChapters,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
@@ -853,7 +854,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
padding: const EdgeInsets.all(24.0),
child: Center(
child: Text(
'No chapter information available',
S.of(context)!.noChapterInfoAvailable,
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
@@ -914,7 +915,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
),
subtitle: isInProgress
? Text(
'In progress',
S.of(context)!.inProgress,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
@@ -972,7 +973,7 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
children: [
const Icon(Icons.play_arrow, size: 20),
const SizedBox(width: 8),
Text(isInProgress ? 'Resume' : 'Play'),
Text(isInProgress ? S.of(context)!.resume : S.of(context)!.play),
],
),
),
+14 -12
View File
@@ -11,6 +11,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import 'audiobook_detail_screen.dart';
import '../l10n/app_localizations.dart';
class AudiobookSeriesScreen extends StatefulWidget {
final AudiobookSeries series;
@@ -168,7 +169,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
if (maProvider.api == null) {
setState(() {
_error = 'Not connected to Music Assistant';
_error = S.of(context)!.notConnected;
_isLoading = false;
});
return;
@@ -270,6 +271,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.watch<MusicAssistantProvider>();
final l10n = S.of(context)!;
return Scaffold(
backgroundColor: colorScheme.surface,
@@ -335,8 +337,8 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
const SizedBox(height: 8),
Text(
_isLoading
? 'Loading...'
: '${_audiobooks.length} ${_audiobooks.length == 1 ? 'book' : 'books'}',
? l10n.loading
: l10n.bookCount(_audiobooks.length),
style: textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
@@ -353,7 +355,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
child: Row(
children: [
Text(
'Books',
l10n.books,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
@@ -367,7 +369,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
color: colorScheme.primary,
size: 20,
),
tooltip: _sortOrder == 'series' ? 'Sort alphabetically' : 'Sort by series order',
tooltip: _sortOrder == 'series' ? S.of(context)!.sortAlphabetically : S.of(context)!.sortBySeriesOrder,
onPressed: _toggleSortOrder,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -385,10 +387,10 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
size: 20,
),
tooltip: _viewMode == 'grid2'
? '3-column grid'
? S.of(context)!.threeColumnGrid
: _viewMode == 'grid3'
? 'List view'
: '2-column grid',
? S.of(context)!.listView
: S.of(context)!.twoColumnGrid,
onPressed: _cycleViewMode,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
@@ -416,7 +418,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadSeriesBooks,
child: const Text('Retry'),
child: Text(l10n.retry),
),
],
),
@@ -432,7 +434,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
size: 48, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'No books found in this series',
S.of(context)!.noBooksInSeries,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
],
@@ -708,7 +710,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
),
if (book.authorsString.isNotEmpty && book.authorsString != 'Unknown Author')
if (book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor)
Text(
book.authorsString,
style: textTheme.bodySmall?.copyWith(
@@ -790,7 +792,7 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
subtitle: Text(
book.authorsString.isNotEmpty && book.authorsString != 'Unknown Author'
book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor
? book.authorsString
: book.duration != null
? _formatDuration(book.duration!)
+46 -45
View File
@@ -5,6 +5,7 @@ import 'package:share_plus/share_plus.dart';
import '../services/debug_logger.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../l10n/app_localizations.dart';
class DebugLogScreen extends StatefulWidget {
const DebugLogScreen({super.key});
@@ -42,10 +43,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
void _copyLogs() {
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard'),
SnackBar(
content: Text(S.of(context)!.logsCopied),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
duration: const Duration(seconds: 2),
),
);
}
@@ -58,7 +59,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
await Share.share(
report,
subject: 'Ensemble Bug Report',
subject: S.of(context)!.ensembleBugReport,
);
} finally {
if (mounted) {
@@ -72,9 +73,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
_logger.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs cleared'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.logsCleared),
duration: const Duration(seconds: 1),
),
);
}
@@ -93,7 +94,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF2a2a2a),
title: Text(
'All Players (${allPlayers.length})',
S.of(context)!.allPlayersCount(allPlayers.length),
style: const TextStyle(color: Colors.white),
),
content: SizedBox(
@@ -153,27 +154,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ID: ${player.playerId}',
S.of(context)!.playerId(player.playerId),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
Text(
'Available: ${player.available} | Provider: ${player.provider}',
S.of(context)!.playerInfo(player.available.toString(), player.provider ?? 'unknown'),
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
if (isCurrentPlayer)
const Text(
'← This device',
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
Text(
S.of(context)!.thisDevice,
style: const TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
),
if (isGhost && !isCorrupt)
const Text(
'⚠️ Ghost player (duplicate)',
style: TextStyle(color: Colors.red, fontSize: 11),
Text(
S.of(context)!.ghostPlayer,
style: const TextStyle(color: Colors.red, fontSize: 11),
),
if (isCorrupt)
const Text(
'⚠️ Unavailable/Corrupt',
style: TextStyle(color: Colors.orange, fontSize: 11),
Text(
S.of(context)!.unavailableCorrupt,
style: const TextStyle(color: Colors.orange, fontSize: 11),
),
],
),
@@ -195,15 +196,15 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Player list copied to clipboard!')),
SnackBar(content: Text(S.of(context)!.playerListCopied)),
);
}
},
child: const Text('Copy List'),
child: Text(S.of(context)!.copyList),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(S.of(context)!.close),
),
],
),
@@ -211,7 +212,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading players: $e')),
SnackBar(content: Text(S.of(context)!.errorLoadingPlayers(e.toString()))),
);
}
}
@@ -229,9 +230,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
onPressed: () => Navigator.pop(context),
color: Colors.white,
),
title: const Text(
'Debug Logs',
style: TextStyle(
title: Text(
S.of(context)!.debugLogs,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w300,
@@ -256,11 +257,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
icon: const Icon(Icons.share_rounded),
onPressed: _shareBugReport,
color: Colors.white,
tooltip: 'Share bug report',
tooltip: S.of(context)!.shareBugReport,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white),
tooltip: 'More options',
tooltip: S.of(context)!.moreOptions,
onSelected: (value) async {
switch (value) {
case 'show_players':
@@ -275,27 +276,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
}
},
itemBuilder: (context) => [
const PopupMenuItem(
PopupMenuItem(
value: 'show_players',
child: ListTile(
leading: Icon(Icons.speaker_group_rounded),
title: Text('View All Players'),
leading: const Icon(Icons.speaker_group_rounded),
title: Text(S.of(context)!.viewAllPlayers),
dense: true,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'copy_logs',
child: ListTile(
leading: Icon(Icons.copy_rounded),
title: Text('Copy Logs'),
leading: const Icon(Icons.copy_rounded),
title: Text(S.of(context)!.copyLogs),
dense: true,
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'clear_logs',
child: ListTile(
leading: Icon(Icons.delete_rounded),
title: Text('Clear Logs'),
leading: const Icon(Icons.delete_rounded),
title: Text(S.of(context)!.clearLogs),
dense: true,
),
),
@@ -317,20 +318,20 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('All', LogLevel.debug),
_buildFilterChip(S.of(context)!.all, LogLevel.debug),
const SizedBox(width: 8),
_buildFilterChip('Info+', LogLevel.info),
_buildFilterChip(S.of(context)!.infoPlus, LogLevel.info),
const SizedBox(width: 8),
_buildFilterChip('Warnings', LogLevel.warning),
_buildFilterChip(S.of(context)!.warnings, LogLevel.warning),
const SizedBox(width: 8),
_buildFilterChip('Errors', LogLevel.error),
_buildFilterChip(S.of(context)!.errors, LogLevel.error),
],
),
),
const SizedBox(height: 8),
// Stats
Text(
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
S.of(context)!.showingEntries(_filteredEntries.length, _logger.entries.length),
style: const TextStyle(color: Colors.white54, fontSize: 11),
),
],
@@ -338,10 +339,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
),
Expanded(
child: _filteredEntries.isEmpty
? const Center(
? Center(
child: Text(
'No logs yet',
style: TextStyle(
S.of(context)!.noLogsYet,
style: const TextStyle(
color: Colors.white54,
fontSize: 16,
),
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/navigation_provider.dart';
import '../widgets/global_player_overlay.dart';
import 'new_home_screen.dart';
@@ -39,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Press back again to minimize'),
content: Text(S.of(context)!.pressBackToMinimize),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
+3 -1
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/album_card.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryAlbumsScreen extends StatelessWidget {
const LibraryAlbumsScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Albums',
S.of(context)!.albums,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -54,6 +55,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
if (albums.isEmpty) {
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+3 -1
View File
@@ -6,6 +6,7 @@ import '../utils/page_transitions.dart';
import '../widgets/common/empty_state.dart';
import '../widgets/artist_avatar.dart';
import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryArtistsScreen extends StatelessWidget {
const LibraryArtistsScreen({super.key});
@@ -27,7 +28,7 @@ class LibraryArtistsScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Artists',
S.of(context)!.artists,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -56,6 +57,7 @@ class LibraryArtistsScreen extends StatelessWidget {
if (artists.isEmpty) {
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
+5 -4
View File
@@ -5,6 +5,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import 'playlist_details_screen.dart';
import '../l10n/app_localizations.dart';
class LibraryPlaylistsScreen extends StatefulWidget {
const LibraryPlaylistsScreen({super.key});
@@ -59,7 +60,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
color: colorScheme.onBackground,
),
title: Text(
'Playlists',
S.of(context)!.playlists,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -81,7 +82,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
}
if (_playlists.isEmpty) {
return EmptyState.playlists(onRefresh: _loadPlaylists);
return EmptyState.playlists(context: context, onRefresh: _loadPlaylists);
}
return RefreshIndicator(
@@ -138,8 +139,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} tracks'
: playlist.owner ?? 'Playlist',
? '${playlist.trackCount} ${S.of(context)!.tracks}'
: playlist.owner ?? S.of(context)!.playlist,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
+4 -3
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../providers/music_assistant_provider.dart';
import '../models/media_item.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class LibraryTracksScreen extends StatelessWidget {
const LibraryTracksScreen({super.key});
@@ -25,7 +26,7 @@ class LibraryTracksScreen extends StatelessWidget {
color: colorScheme.onBackground,
),
title: Text(
'Tracks',
S.of(context)!.tracks,
style: textTheme.headlineSmall?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -48,7 +49,7 @@ class LibraryTracksScreen extends StatelessWidget {
}
if (provider.tracks.isEmpty) {
return EmptyState.tracks(onRefresh: provider.loadLibrary);
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
}
return ListView.builder(
@@ -129,7 +130,7 @@ class LibraryTracksScreen extends StatelessWidget {
if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
+40 -39
View File
@@ -7,6 +7,7 @@ import '../services/profile_service.dart';
import '../services/auth/auth_strategy.dart';
import '../services/debug_logger.dart';
import '../widgets/debug/debug_console.dart';
import '../l10n/app_localizations.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
@@ -158,7 +159,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _detectAuthRequirements() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your Music Assistant server address';
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
@@ -194,7 +195,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (strategy == null) {
setState(() {
_error = 'Cannot determine authentication requirements. Please check server URL.';
_error = S.of(context)!.cannotDetermineAuth;
_isDetectingAuth = false;
});
return;
@@ -230,13 +231,13 @@ class _LoginScreenState extends State<LoginScreen> {
String _getAuthTypeName(String strategyName) {
switch (strategyName) {
case 'none':
return 'No Authentication';
return S.of(context)!.noAuthentication;
case 'basic':
return 'HTTP Basic Auth';
return S.of(context)!.httpBasicAuth;
case 'authelia':
return 'Authelia';
return S.of(context)!.authelia;
case 'music_assistant':
return 'Music Assistant Login';
return S.of(context)!.musicAssistantLogin;
default:
return 'Unknown';
}
@@ -260,7 +261,7 @@ class _LoginScreenState extends State<LoginScreen> {
Future<void> _connect() async {
if (_serverUrlController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your Music Assistant server address';
_error = S.of(context)!.pleaseEnterServerAddress;
});
return;
}
@@ -270,7 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
setState(() {
_error = 'Please enter your name';
_error = S.of(context)!.pleaseEnterName;
});
return;
}
@@ -292,7 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
portNum = int.tryParse(port);
if (portNum == null || portNum < 1 || portNum > 65535) {
setState(() {
_error = 'Please enter a valid port number (1-65535)';
_error = S.of(context)!.pleaseEnterValidPort;
_isConnecting = false;
});
return;
@@ -324,7 +325,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = 'Please enter username and password';
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
@@ -350,7 +351,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!success) {
setState(() {
_error = 'Authentication failed. Please check your credentials.';
_error = S.of(context)!.authFailed;
_isConnecting = false;
});
return;
@@ -384,7 +385,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (username.isEmpty || password.isEmpty) {
setState(() {
_error = 'Please enter username and password';
_error = S.of(context)!.pleaseEnterCredentials;
_isConnecting = false;
});
return;
@@ -408,7 +409,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (accessToken == null) {
setState(() {
_error = 'Music Assistant login failed. Please check your credentials.';
_error = S.of(context)!.maLoginFailed;
_isConnecting = false;
});
return;
@@ -452,7 +453,7 @@ class _LoginScreenState extends State<LoginScreen> {
}
} else {
setState(() {
_error = 'Could not connect to server. Please check the address and try again.';
_error = S.of(context)!.connectionFailed;
_isConnecting = false;
});
}
@@ -500,7 +501,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Server URL
Text(
'Server Address',
S.of(context)!.serverAddress,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -512,7 +513,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _serverUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., music.example.com or 192.168.1.100',
hintText: S.of(context)!.serverAddressHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -539,7 +540,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (_detectedAuthStrategy != null &&
_detectedAuthStrategy!.name != 'music_assistant') ...[
Text(
'Your Name',
S.of(context)!.yourName,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -551,7 +552,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _ownerNameController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Your first name',
hintText: S.of(context)!.yourFirstName,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -574,7 +575,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Port
Text(
'Port (Optional)',
S.of(context)!.portOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -582,7 +583,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 8),
Text(
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.',
S.of(context)!.portDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
@@ -594,7 +595,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _portController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., 8095 or leave blank',
hintText: S.of(context)!.portHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -639,7 +640,7 @@ class _LoginScreenState extends State<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Detected: $_detectedAuthType',
S.of(context)!.detectedAuthType(_detectedAuthType!),
style: TextStyle(
color: needsAuth
? colorScheme.onPrimaryContainer
@@ -652,7 +653,7 @@ class _LoginScreenState extends State<LoginScreen> {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'This server requires authentication',
S.of(context)!.serverRequiresAuth,
style: TextStyle(
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
fontSize: 12,
@@ -698,8 +699,8 @@ class _LoginScreenState extends State<LoginScreen> {
children: [
Text(
_isTailscaleUrl(_serverUrlController.text)
? 'Tailscale VPN Connection'
: 'Unencrypted Connection',
? S.of(context)!.tailscaleVpnConnection
: S.of(context)!.unencryptedConnection,
style: TextStyle(
color: _isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
@@ -712,10 +713,10 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.only(top: 4),
child: Text(
_isTailscaleUrl(_serverUrlController.text)
? 'Using HTTP over Tailscale (encrypted tunnel)'
? S.of(context)!.usingHttpOverTailscale
: _usedHttpFallback
? 'HTTPS failed, using HTTP fallback'
: 'HTTP connection - data is not encrypted',
? S.of(context)!.httpsFailedUsingHttp
: S.of(context)!.httpNotEncrypted,
style: TextStyle(
color: (_isTailscaleUrl(_serverUrlController.text)
? colorScheme.onSecondaryContainer
@@ -736,7 +737,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Auth Server URL (only for Authelia)
if (_detectedAuthStrategy?.name == 'authelia') ...[
Text(
'Auth Server URL (Optional)',
S.of(context)!.authServerUrlOptional,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -744,7 +745,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 8),
Text(
'Leave empty if authentication is on the same server',
S.of(context)!.authServerUrlDescription,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.6),
fontSize: 12,
@@ -756,7 +757,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _authServerUrlController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'e.g., auth.example.com (if different from server)',
hintText: S.of(context)!.authServerUrlHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -777,7 +778,7 @@ class _LoginScreenState extends State<LoginScreen> {
],
Text(
'Username',
S.of(context)!.username,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -790,7 +791,7 @@ class _LoginScreenState extends State<LoginScreen> {
focusNode: _usernameFocusNode,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Username',
hintText: S.of(context)!.username,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -810,7 +811,7 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 16),
Text(
'Password',
S.of(context)!.password,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w500,
@@ -822,7 +823,7 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'Password',
hintText: S.of(context)!.password,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -912,9 +913,9 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(width: 12),
const Text(
'Detecting authentication...',
style: TextStyle(
Text(
S.of(context)!.detectingAuth,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -931,7 +932,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
)
: Text(
_detectedAuthStrategy == null ? 'Detect & Connect' : 'Connect',
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
+14 -11
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
@@ -179,6 +180,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
// Only show disconnected state if we have no cached data at all
if (!isConnected && !syncService.hasCache) {
return DisconnectedState.full(
context: context,
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -196,7 +198,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
child: _buildConnectedView(context, maProvider),
),
// Connecting banner overlay (doesn't affect layout)
if (!isConnected && syncService.hasCache)
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !maProvider.hasCachedPlayers)
Positioned(
top: 0,
left: 0,
@@ -217,7 +220,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
),
const SizedBox(width: 8),
Text(
'Connecting...',
S.of(context)!.connecting,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
@@ -299,7 +302,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showRecentAlbums) return null;
return AlbumRow(
key: const ValueKey('recent-albums'),
title: 'Recently Played',
title: S.of(context)!.recentlyPlayed,
loadAlbums: () => provider.getRecentAlbumsWithCache(),
rowHeight: rowHeight,
);
@@ -307,7 +310,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverArtists) return null;
return ArtistRow(
key: const ValueKey('discover-artists'),
title: 'Discover Artists',
title: S.of(context)!.discoverArtists,
loadArtists: () => provider.getDiscoverArtistsWithCache(),
rowHeight: rowHeight,
);
@@ -315,7 +318,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverAlbums) return null;
return AlbumRow(
key: const ValueKey('discover-albums'),
title: 'Discover Albums',
title: S.of(context)!.discoverAlbums,
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
rowHeight: rowHeight,
);
@@ -323,7 +326,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showContinueListeningAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('continue-listening'),
title: 'Continue Listening',
title: S.of(context)!.continueListening,
loadAudiobooks: () => provider.getInProgressAudiobooks(),
rowHeight: rowHeight,
);
@@ -331,7 +334,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverAudiobooks) return null;
return AudiobookRow(
key: const ValueKey('discover-audiobooks'),
title: 'Discover Audiobooks',
title: S.of(context)!.discoverAudiobooks,
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
rowHeight: rowHeight,
);
@@ -339,7 +342,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showDiscoverSeries) return null;
return SeriesRow(
key: const ValueKey('discover-series'),
title: 'Discover Series',
title: S.of(context)!.discoverSeries,
loadSeries: () => provider.getDiscoverSeries(),
rowHeight: rowHeight,
);
@@ -347,7 +350,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteAlbums) return null;
return AlbumRow(
key: const ValueKey('favorite-albums'),
title: 'Favorite Albums',
title: S.of(context)!.favoriteAlbums,
loadAlbums: () => provider.getFavoriteAlbums(),
rowHeight: rowHeight,
);
@@ -355,7 +358,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteArtists) return null;
return ArtistRow(
key: const ValueKey('favorite-artists'),
title: 'Favorite Artists',
title: S.of(context)!.favoriteArtists,
loadArtists: () => provider.getFavoriteArtists(),
rowHeight: rowHeight,
);
@@ -363,7 +366,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
if (!_showFavoriteTracks) return null;
return TrackRow(
key: const ValueKey('favorite-tracks'),
title: 'Favorite Tracks',
title: S.of(context)!.favoriteTracks,
loadTracks: () => provider.getFavoriteTracks(),
rowHeight: rowHeight,
);
+94 -80
View File
@@ -18,6 +18,7 @@ import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/sync_service.dart';
import '../l10n/app_localizations.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import 'playlist_details_screen.dart';
@@ -652,6 +653,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
@override
Widget build(BuildContext context) {
final l10n = S.of(context)!;
// Use Selector for targeted rebuilds - only rebuild when connection state changes
return Selector<MusicAssistantProvider, bool>(
selector: (_, provider) => provider.isConnected,
@@ -669,7 +672,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
'Library',
l10n.library,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -679,6 +682,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
actions: const [PlayerSelector()],
),
body: DisconnectedState.withSettingsAction(
context: context,
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
@@ -727,7 +731,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w400, fontSize: 14),
tabs: _buildTabs(),
tabs: _buildTabs(l10n),
),
),
// Favorites + Layout buttons on the right
@@ -738,7 +742,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
size: 20,
),
onPressed: () => _toggleFavoritesMode(!_showFavoritesOnly),
tooltip: _showFavoritesOnly ? 'Show all' : 'Show favorites only',
tooltip: _showFavoritesOnly ? l10n.showAll : l10n.showFavoritesOnly,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
@@ -750,7 +754,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
size: 20,
),
onPressed: _cycleCurrentViewMode,
tooltip: 'Change view',
tooltip: l10n.changeView,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
@@ -765,7 +769,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
body: Column(
children: [
// Connecting banner when showing cached data
if (!isConnected && syncService.hasCache)
// Hide when we have cached players - UI is functional during background reconnect
if (!isConnected && syncService.hasCache && !context.read<MusicAssistantProvider>().hasCachedPlayers)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -783,7 +788,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
const SizedBox(width: 8),
Text(
'Connecting...',
l10n.connecting,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer,
),
@@ -794,7 +799,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
Expanded(
child: TabBarView(
controller: _tabController,
children: _buildTabViews(context),
children: _buildTabViews(context, l10n),
),
),
],
@@ -858,54 +863,53 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ CONTEXTUAL TABS ============
List<Tab> _buildTabs() {
List<Tab> _buildTabs(S l10n) {
switch (_selectedMediaType) {
case LibraryMediaType.music:
return [
const Tab(text: 'Artists'),
const Tab(text: 'Albums'),
if (_showFavoritesOnly) const Tab(text: 'Tracks'),
const Tab(text: 'Playlists'),
Tab(text: l10n.artists),
Tab(text: l10n.albums),
if (_showFavoritesOnly) Tab(text: l10n.tracks),
Tab(text: l10n.playlists),
];
case LibraryMediaType.books:
return const [
Tab(text: 'Authors'),
Tab(text: 'All Books'),
Tab(text: 'Series'),
return [
Tab(text: l10n.authors),
Tab(text: l10n.books),
Tab(text: l10n.series),
];
case LibraryMediaType.podcasts:
return const [
Tab(text: 'Shows'),
return [
Tab(text: l10n.shows),
];
}
}
List<Widget> _buildTabViews(BuildContext context) {
List<Widget> _buildTabViews(BuildContext context, S l10n) {
switch (_selectedMediaType) {
case LibraryMediaType.music:
return [
_buildArtistsTab(context),
_buildAlbumsTab(context),
if (_showFavoritesOnly) _buildTracksTab(context),
_buildPlaylistsTab(context),
_buildArtistsTab(context, l10n),
_buildAlbumsTab(context, l10n),
if (_showFavoritesOnly) _buildTracksTab(context, l10n),
_buildPlaylistsTab(context, l10n),
];
case LibraryMediaType.books:
return [
_buildBooksAuthorsTab(context),
_buildAllBooksTab(context),
_buildSeriesTab(context),
_buildBooksAuthorsTab(context, l10n),
_buildAllBooksTab(context, l10n),
_buildSeriesTab(context, l10n),
];
case LibraryMediaType.podcasts:
return [
_buildPodcastsComingSoonTab(context),
_buildPodcastsComingSoonTab(context, l10n),
];
}
}
// ============ BOOKS TABS ============
Widget _buildBooksAuthorsTab(BuildContext context) {
Widget _buildBooksAuthorsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
if (_isLoadingAudiobooks) {
return Center(child: CircularProgressIndicator(color: colorScheme.primary));
@@ -919,15 +923,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (audiobooks.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite audiobooks',
subtitle: 'Tap the heart on an audiobook to add it to favorites',
title: l10n.noFavoriteAudiobooks,
subtitle: l10n.tapHeartAudiobook,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline,
title: 'No audiobooks',
subtitle: 'Add audiobooks to your library to see them here',
title: l10n.noAudiobooks,
subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(),
);
}
@@ -956,7 +962,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: sortedAuthors.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
return _buildAuthorListTile(sortedAuthors[index], authorMap[sortedAuthors[index]]!);
return _buildAuthorListTile(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n);
},
)
: GridView.builder(
@@ -973,13 +979,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
itemCount: sortedAuthors.length,
itemBuilder: (context, index) {
return _buildAuthorCard(sortedAuthors[index], authorMap[sortedAuthors[index]]!);
return _buildAuthorCard(sortedAuthors[index], authorMap[sortedAuthors[index]]!, l10n);
},
),
);
}
Widget _buildAuthorListTile(String authorName, List<Audiobook> books) {
Widget _buildAuthorListTile(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName];
@@ -1036,7 +1042,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${books.length} ${books.length == 1 ? 'audiobook' : 'audiobooks'}',
'${books.length} ${books.length == 1 ? l10n.audiobookSingular : l10n.audiobooks}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
@@ -1045,7 +1051,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildAuthorCard(String authorName, List<Audiobook> books) {
Widget _buildAuthorCard(String authorName, List<Audiobook> books, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final authorImageUrl = _authorImages[authorName];
@@ -1232,7 +1238,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildAllBooksTab(BuildContext context) {
Widget _buildAllBooksTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final maProvider = context.read<MusicAssistantProvider>();
@@ -1248,15 +1254,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (audiobooks.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite audiobooks',
subtitle: 'Tap the heart on an audiobook to add it to favorites',
title: l10n.noFavoriteAudiobooks,
subtitle: l10n.tapHeartAudiobook,
);
}
return EmptyState.custom(
context: context,
icon: MdiIcons.bookOutline,
title: 'No audiobooks',
subtitle: 'Add audiobooks to your library to see them here',
title: l10n.noAudiobooks,
subtitle: l10n.addAudiobooksHint,
onRefresh: () => _loadAudiobooks(),
);
}
@@ -1405,7 +1413,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildSeriesTab(BuildContext context) {
Widget _buildSeriesTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final maProvider = context.read<MusicAssistantProvider>();
@@ -1419,7 +1427,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
CircularProgressIndicator(color: colorScheme.primary),
const SizedBox(height: 16),
Text(
'Loading series...',
l10n.loading,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14,
@@ -1452,7 +1460,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
const SizedBox(height: 16),
Text(
'No Series Found',
l10n.noSeriesFound,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
@@ -1462,8 +1470,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
const SizedBox(height: 8),
Text(
_seriesLoaded
? 'No series available from your audiobook library.\nPull to refresh.'
: 'Pull down to load series\nfrom Music Assistant',
? l10n.noSeriesAvailable
: l10n.pullToLoadSeries,
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
@@ -1473,7 +1481,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: _loadSeries,
child: const Text('Load Series'),
child: Text(l10n.loadSeries),
),
],
),
@@ -1497,7 +1505,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _series.length,
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.withMiniPlayer),
itemBuilder: (context, index) {
return _buildSeriesListTile(context, _series[index], maProvider);
return _buildSeriesListTile(context, _series[index], maProvider, l10n);
},
)
: GridView.builder(
@@ -1512,13 +1520,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _series.length,
itemBuilder: (context, index) {
final series = _series[index];
return _buildSeriesCard(context, series, maProvider, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3);
return _buildSeriesCard(context, series, maProvider, l10n, maxCoverGridSize: _seriesViewMode == 'grid3' ? 2 : 3);
},
),
);
}
Widget _buildSeriesListTile(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider) {
Widget _buildSeriesListTile(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -1576,7 +1584,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final count = series.bookCount ?? _seriesBookCounts[series.id];
if (count == null) return const SizedBox.shrink();
return Text(
'$count ${count == 1 ? 'book' : 'books'}',
'$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
@@ -1599,7 +1607,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildSeriesCard(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, {int maxCoverGridSize = 3}) {
Widget _buildSeriesCard(BuildContext context, AudiobookSeries series, MusicAssistantProvider maProvider, S l10n, {int maxCoverGridSize = 3}) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
@@ -1661,7 +1669,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
return Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'$count ${count == 1 ? 'book' : 'books'}',
'$count ${count == 1 ? l10n.book : l10n.books}',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
@@ -1833,7 +1841,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ PODCASTS TAB (Placeholder) ============
Widget _buildPodcastsComingSoonTab(BuildContext context) {
Widget _buildPodcastsComingSoonTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
@@ -1846,7 +1854,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
const SizedBox(height: 16),
Text(
'Podcasts',
l10n.podcasts,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
@@ -1855,7 +1863,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
const SizedBox(height: 8),
Text(
'Podcast support coming soon',
l10n.podcastSupportComingSoon,
style: TextStyle(
color: colorScheme.onSurface.withOpacity(0.6),
fontSize: 14,
@@ -1867,7 +1875,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ARTISTS TAB ============
Widget _buildArtistsTab(BuildContext context) {
Widget _buildArtistsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when artists or loading state changes
return Selector<MusicAssistantProvider, (List<Artist>, bool)>(
selector: (_, provider) => (provider.artists, provider.isLoading),
@@ -1887,12 +1895,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (artists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite artists',
subtitle: 'Tap the heart on an artist to add them to favorites',
title: l10n.noFavoriteArtists,
subtitle: l10n.tapHeartArtist,
);
}
return EmptyState.artists(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
@@ -2050,7 +2060,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ ALBUMS TAB ============
Widget _buildAlbumsTab(BuildContext context) {
Widget _buildAlbumsTab(BuildContext context, S l10n) {
// Use Selector for targeted rebuilds - only rebuild when albums or loading state changes
return Selector<MusicAssistantProvider, (List<Album>, bool)>(
selector: (_, provider) => (provider.albums, provider.isLoading),
@@ -2070,12 +2080,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (albums.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite albums',
subtitle: 'Tap the heart on an album to add it to favorites',
title: l10n.noFavoriteAlbums,
subtitle: l10n.tapHeartAlbum,
);
}
return EmptyState.albums(
context: context,
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
);
}
@@ -2184,7 +2196,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ PLAYLISTS TAB ============
Widget _buildPlaylistsTab(BuildContext context) {
Widget _buildPlaylistsTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingPlaylists) {
@@ -2194,12 +2206,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (_playlists.isEmpty) {
if (_showFavoritesOnly) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite playlists',
subtitle: 'Tap the heart on a playlist to add it to favorites',
title: l10n.noFavoritePlaylists,
subtitle: l10n.tapHeartPlaylist,
);
}
return EmptyState.playlists(onRefresh: () => _loadPlaylists());
return EmptyState.playlists(context: context, onRefresh: () => _loadPlaylists());
}
return RefreshIndicator(
@@ -2216,7 +2229,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
padding: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: BottomSpacing.navBarOnly),
itemBuilder: (context, index) {
final playlist = _playlists[index];
return _buildPlaylistTile(context, playlist);
return _buildPlaylistTile(context, playlist, l10n);
},
)
: GridView.builder(
@@ -2234,13 +2247,13 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
itemCount: _playlists.length,
itemBuilder: (context, index) {
final playlist = _playlists[index];
return _buildPlaylistGridCard(context, playlist);
return _buildPlaylistGridCard(context, playlist, l10n);
},
),
);
}
Widget _buildPlaylistTile(BuildContext context, Playlist playlist) {
Widget _buildPlaylistTile(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 128);
final colorScheme = Theme.of(context).colorScheme;
@@ -2274,8 +2287,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
subtitle: Text(
playlist.trackCount != null
? '${playlist.trackCount} tracks'
: playlist.owner ?? 'Playlist',
? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -2299,7 +2312,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
Widget _buildPlaylistGridCard(BuildContext context, Playlist playlist) {
Widget _buildPlaylistGridCard(BuildContext context, Playlist playlist, S l10n) {
final provider = context.read<MusicAssistantProvider>();
final imageUrl = provider.api?.getImageUrl(playlist, size: 256);
final colorScheme = Theme.of(context).colorScheme;
@@ -2363,8 +2376,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
Text(
playlist.trackCount != null
? '${playlist.trackCount} tracks'
: playlist.owner ?? 'Playlist',
? '${playlist.trackCount} ${l10n.tracks}'
: playlist.owner ?? l10n.playlist,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
@@ -2377,7 +2390,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
// ============ TRACKS TAB (favorites only) ============
Widget _buildTracksTab(BuildContext context) {
Widget _buildTracksTab(BuildContext context, S l10n) {
final colorScheme = Theme.of(context).colorScheme;
if (_isLoadingTracks) {
@@ -2386,9 +2399,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (_favoriteTracks.isEmpty) {
return EmptyState.custom(
context: context,
icon: Icons.favorite_border,
title: 'No favorite tracks',
subtitle: 'Long-press a track and tap the heart to add it to favorites',
title: l10n.noFavoriteTracks,
subtitle: l10n.longPressTrackHint,
);
}
@@ -2474,7 +2488,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
final player = maProvider.selectedPlayer;
if (player == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
+12 -11
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../services/debug_logger.dart';
@@ -61,7 +62,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
// Use the selected player
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -77,7 +78,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${widget.playlist.name}'),
content: Text(S.of(context)!.playing(widget.playlist.name)),
duration: const Duration(seconds: 2),
),
);
@@ -195,7 +196,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
child: ElevatedButton.icon(
onPressed: _isLoading ? null : _playPlaylist,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play'),
label: Text(S.of(context)!.play),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1a1a1a),
@@ -208,9 +209,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
),
const SizedBox(height: 24),
// Tracks header
const Text(
'Tracks',
style: TextStyle(
Text(
S.of(context)!.tracks,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -230,7 +231,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
? SliverFillRemaining(
child: Center(
child: Text(
'No tracks in playlist',
S.of(context)!.noTracksInPlaylist,
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
@@ -327,7 +328,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
onPressed: () async {
final player = maProvider.selectedPlayer;
if (player == null) {
_showError('No player selected');
_showError(S.of(context)!.noPlayerSelected);
return;
}
@@ -336,9 +337,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
await maProvider.playTracks(player.playerId, _tracks, startIndex: index);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Playing track'),
duration: Duration(seconds: 1),
SnackBar(
content: Text(S.of(context)!.playingTrack),
duration: const Duration(seconds: 1),
),
);
}
+10 -9
View File
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../widgets/common/empty_state.dart';
import '../l10n/app_localizations.dart';
class QueueScreen extends StatefulWidget {
const QueueScreen({super.key});
@@ -51,7 +52,7 @@ class _QueueScreenState extends State<QueueScreen> {
if (mounted) {
setState(() {
_isLoading = false;
_error = 'No player selected';
_error = S.of(context)!.noPlayerSelected;
});
}
}
@@ -77,7 +78,7 @@ class _QueueScreenState extends State<QueueScreen> {
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
player != null ? '${player.name} Queue' : 'Queue',
player != null ? S.of(context)!.playerQueue(player.name) : S.of(context)!.queue,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
@@ -116,7 +117,7 @@ class _QueueScreenState extends State<QueueScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadQueue,
child: const Text('Retry'),
child: Text(S.of(context)!.retry),
),
],
),
@@ -131,7 +132,7 @@ class _QueueScreenState extends State<QueueScreen> {
Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]),
const SizedBox(height: 16),
Text(
'No player selected',
S.of(context)!.noPlayerSelected,
style: TextStyle(color: Colors.grey[600], fontSize: 18),
),
],
@@ -140,7 +141,7 @@ class _QueueScreenState extends State<QueueScreen> {
}
if (_queue == null || _queue!.items.isEmpty) {
return EmptyState.queue();
return EmptyState.queue(context: context);
}
// Filter to show only current and upcoming items (not history)
@@ -148,9 +149,9 @@ class _QueueScreenState extends State<QueueScreen> {
final upcomingItems = _queue!.items.sublist(currentIndex);
if (upcomingItems.isEmpty) {
return const EmptyState(
return EmptyState(
icon: Icons.queue_music,
message: 'No upcoming tracks',
message: S.of(context)!.noUpcomingTracks,
);
}
@@ -220,9 +221,9 @@ class _QueueScreenState extends State<QueueScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Removed ${item.track.name}'),
content: Text(S.of(context)!.removedItem(item.track.name)),
action: SnackBarAction(
label: 'Undo',
label: S.of(context)!.undo,
onPressed: () {
setState(() {
_queue!.items.insert(index, item);
+17 -16
View File
@@ -12,6 +12,7 @@ import '../widgets/common/disconnected_state.dart';
import '../widgets/artist_avatar.dart';
import 'album_details_screen.dart';
import 'artist_details_screen.dart';
import '../l10n/app_localizations.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -140,7 +141,7 @@ class SearchScreenState extends State<SearchScreen> {
setState(() {
_isSearching = false;
_hasSearched = true;
_searchError = 'Search failed. Please check your connection.';
_searchError = S.of(context)!.searchFailed;
});
if (keepFocus && _focusNode.hasFocus) {
_focusNode.requestFocus();
@@ -166,7 +167,7 @@ class SearchScreenState extends State<SearchScreen> {
cursorColor: colorScheme.primary,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search music...',
hintText: S.of(context)!.searchMusic,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
border: InputBorder.none,
suffixIcon: _searchController.text.isNotEmpty
@@ -191,7 +192,7 @@ class SearchScreenState extends State<SearchScreen> {
),
),
body: !maProvider.isConnected
? DisconnectedState.simple()
? DisconnectedState.simple(context)
: _buildSearchContent(),
);
}
@@ -217,7 +218,7 @@ class SearchScreenState extends State<SearchScreen> {
),
const SizedBox(height: 16),
Text(
'Search for artists, albums, or tracks',
S.of(context)!.searchForContent,
style: TextStyle(
color: colorScheme.onBackground.withOpacity(0.5),
fontSize: 16,
@@ -250,7 +251,7 @@ class SearchScreenState extends State<SearchScreen> {
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => _performSearch(_searchController.text),
child: const Text('Retry'),
child: Text(S.of(context)!.retry),
),
],
),
@@ -264,7 +265,7 @@ class SearchScreenState extends State<SearchScreen> {
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
if (!hasResults) {
return EmptyState.search();
return EmptyState.search(context: context);
}
return Column(
@@ -275,23 +276,23 @@ class SearchScreenState extends State<SearchScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
_buildFilterChip('All', 'all'),
_buildFilterChip(S.of(context)!.all, 'all'),
const SizedBox(width: 8),
if (artists.isNotEmpty) ...[
_buildFilterChip('Artists', 'artists'),
_buildFilterChip(S.of(context)!.artists, 'artists'),
const SizedBox(width: 8),
],
if (albums.isNotEmpty) ...[
_buildFilterChip('Albums', 'albums'),
_buildFilterChip(S.of(context)!.albums, 'albums'),
const SizedBox(width: 8),
],
if (tracks.isNotEmpty) ...[
_buildFilterChip('Tracks', 'tracks'),
_buildFilterChip(S.of(context)!.tracks, 'tracks'),
],
],
),
),
// Results
Expanded(
child: Builder(
@@ -336,7 +337,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add artists section
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Artists', artists.length));
items.add(_ListItem.header(S.of(context)!.artists, artists.length));
}
for (final artist in artists) {
items.add(_ListItem.artist(artist));
@@ -347,7 +348,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add albums section
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Albums', albums.length));
items.add(_ListItem.header(S.of(context)!.albums, albums.length));
}
for (final album in albums) {
items.add(_ListItem.album(album));
@@ -358,7 +359,7 @@ class SearchScreenState extends State<SearchScreen> {
// Add tracks section
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
if (_activeFilter == 'all') {
items.add(_ListItem.header('Tracks', tracks.length));
items.add(_ListItem.header(S.of(context)!.tracks, tracks.length));
}
for (final track in tracks) {
items.add(_ListItem.track(track));
@@ -428,7 +429,7 @@ class SearchScreenState extends State<SearchScreen> {
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'Artist',
S.of(context)!.artist,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
onTap: () {
@@ -556,7 +557,7 @@ class SearchScreenState extends State<SearchScreen> {
if (maProvider.selectedPlayer == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No player selected')),
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
);
return;
}
+142 -49
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
@@ -106,25 +108,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Helper to get row display info
Map<String, String> _getRowInfo(String rowId) {
final s = S.of(context)!;
switch (rowId) {
case 'recent-albums':
return {'title': 'Recently Played', 'subtitle': 'Show recently played albums'};
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
case 'discover-artists':
return {'title': 'Discover Artists', 'subtitle': 'Show random artists to discover'};
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
case 'discover-albums':
return {'title': 'Discover Albums', 'subtitle': 'Show random albums to discover'};
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
case 'continue-listening':
return {'title': 'Continue Listening', 'subtitle': 'Show audiobooks in progress'};
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
case 'discover-audiobooks':
return {'title': 'Discover Audiobooks', 'subtitle': 'Show random audiobooks to discover'};
return {'title': s.discoverAudiobooks, 'subtitle': s.showRandomAudiobooks};
case 'discover-series':
return {'title': 'Discover Series', 'subtitle': 'Show random audiobook series to discover'};
return {'title': s.discoverSeries, 'subtitle': s.showRandomSeries};
case 'favorite-albums':
return {'title': 'Favorite Albums', 'subtitle': 'Show a row of your favorite albums'};
return {'title': s.favoriteAlbums, 'subtitle': s.showFavoriteAlbums};
case 'favorite-artists':
return {'title': 'Favorite Artists', 'subtitle': 'Show a row of your favorite artists'};
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
case 'favorite-tracks':
return {'title': 'Favorite Tracks', 'subtitle': 'Show a row of your favorite tracks'};
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
default:
return {'title': rowId, 'subtitle': ''};
}
@@ -233,7 +236,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
color: colorScheme.onBackground,
),
title: Text(
'Settings',
S.of(context)!.settings,
style: textTheme.titleLarge?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.w300,
@@ -252,7 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
color: colorScheme.onBackground,
tooltip: 'Debug Logs',
tooltip: S.of(context)!.debugLogs,
),
],
),
@@ -308,9 +311,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: FilledButton.tonalIcon(
onPressed: _disconnect,
icon: const Icon(Icons.logout_rounded),
label: const Text(
'Disconnect',
style: TextStyle(
label: Text(
S.of(context)!.disconnect,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
@@ -329,7 +332,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Theme section
Text(
'Theme',
S.of(context)!.theme,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -340,14 +343,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
color: colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Mode',
S.of(context)!.themeMode,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -358,21 +361,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
return SizedBox(
width: double.infinity,
child: SegmentedButton<ThemeMode>(
segments: const [
segments: [
ButtonSegment<ThemeMode>(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode_rounded),
label: Text(S.of(context)!.light),
icon: const Icon(Icons.light_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode_rounded),
label: Text(S.of(context)!.dark),
icon: const Icon(Icons.dark_mode_rounded),
),
ButtonSegment<ThemeMode>(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.auto_mode_rounded),
label: Text(S.of(context)!.system),
icon: const Icon(Icons.auto_mode_rounded),
),
],
selected: {themeProvider.themeMode},
@@ -384,7 +387,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (states.contains(WidgetState.selected)) {
return colorScheme.primaryContainer;
}
return colorScheme.surfaceVariant.withOpacity(0.3);
return colorScheme.surfaceVariant.withOpacity(0.5);
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
@@ -393,6 +396,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
return colorScheme.onSurfaceVariant;
}),
side: WidgetStateProperty.all(BorderSide.none),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
);
@@ -404,6 +410,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16),
// Language picker
Consumer<LocaleProvider>(
builder: (context, localeProvider, _) {
String getLanguageName(String? code) {
switch (code) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'es':
return 'Español';
default:
return S.of(context)!.system;
}
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
tileColor: colorScheme.surfaceVariant.withOpacity(0.5),
leading: Icon(Icons.language_rounded, color: colorScheme.onSurfaceVariant),
title: Text(S.of(context)!.language),
subtitle: Text(
getLanguageName(localeProvider.locale?.languageCode),
style: TextStyle(color: colorScheme.primary),
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () {
showDialog(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(S.of(context)!.language),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String?>(
title: Text(S.of(context)!.system),
subtitle: const Text('Auto'),
value: null,
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(null);
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('English'),
value: 'en',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('en'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Deutsch'),
value: 'de',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('de'));
Navigator.pop(dialogContext);
},
),
RadioListTile<String?>(
title: const Text('Español'),
value: 'es',
groupValue: localeProvider.locale?.languageCode,
onChanged: (value) {
localeProvider.setLocale(const Locale('es'));
Navigator.pop(dialogContext);
},
),
],
),
);
},
);
},
);
},
),
const SizedBox(height: 16),
Consumer<ThemeProvider>(
builder: (context, themeProvider, _) {
return Container(
@@ -414,11 +505,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
'Material You',
S.of(context)!.materialYou,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Use system colors (Android 12+)',
S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.useMaterialTheme,
@@ -444,11 +535,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
child: SwitchListTile(
title: Text(
'Adaptive Theme',
S.of(context)!.adaptiveTheme,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Extract colors from album and artist artwork',
S.of(context)!.extractColorsFromArtwork,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: themeProvider.adaptiveTheme,
@@ -466,7 +557,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Home Screen section
Text(
'Home Screen',
S.of(context)!.homeScreen,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -474,7 +565,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Choose which rows to display on the home screen',
S.of(context)!.chooseHomeScreenRows,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -551,7 +642,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 32),
Text(
'Metadata APIs (Optional)',
S.of(context)!.metadataApis,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -559,7 +650,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
S.of(context)!.metadataApisDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -570,8 +661,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _lastFmApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Last.fm API Key',
hintText: 'Get free key at last.fm/api',
labelText: S.of(context)!.lastFmApiKey,
hintText: S.of(context)!.lastFmApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -607,8 +698,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
controller: _audioDbApiKeyController,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'TheAudioDB API Key',
hintText: 'Use "2" for free tier or premium key',
labelText: S.of(context)!.theAudioDbApiKey,
hintText: S.of(context)!.theAudioDbApiKeyHint,
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
@@ -643,7 +734,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Audiobook Libraries section
if (_discoveredLibraries.isNotEmpty) ...[
Text(
'Audiobook Libraries',
S.of(context)!.audiobookLibraries,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
@@ -651,7 +742,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Choose which Audiobookshelf libraries to include',
S.of(context)!.chooseAudiobookLibraries,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onBackground.withOpacity(0.6),
),
@@ -668,7 +759,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final index = entry.key;
final library = entry.value;
final path = library['path'] ?? '';
final name = library['name'] ?? 'Unknown Library';
final name = library['name'] ?? S.of(context)!.unknownLibrary;
final isEnabled = _libraryEnabled[path] ?? true;
return Column(
@@ -686,9 +777,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
await SettingsService.toggleAbsLibrary(path, value);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Pull to refresh the library to apply changes'),
duration: Duration(seconds: 2),
SnackBar(
content: Text(S.of(context)!.pullToRefresh),
duration: const Duration(seconds: 2),
),
);
}
@@ -723,7 +814,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
icon: const Icon(Icons.bug_report_rounded),
label: const Text('View Debug Logs'),
label: Text(S.of(context)!.viewDebugLogs),
style: FilledButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
foregroundColor: colorScheme.onSurfaceVariant,
@@ -772,17 +863,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
String _getStatusText(MAConnectionState state) {
final s = S.of(context)!;
switch (state) {
case MAConnectionState.connected:
case MAConnectionState.authenticated:
return 'Connected';
return s.connected;
case MAConnectionState.connecting:
case MAConnectionState.authenticating:
return 'Connecting...';
return s.connecting;
case MAConnectionState.error:
return 'Connection Error';
return s.connectionError;
case MAConnectionState.disconnected:
return 'Disconnected';
return s.disconnected;
}
}
}
+25
View File
@@ -1,4 +1,5 @@
import '../models/media_item.dart';
import '../models/player.dart';
import '../services/debug_logger.dart';
import '../constants/timings.dart';
@@ -28,6 +29,8 @@ class CacheService {
final Map<String, DateTime> _searchCacheTime = {};
// Player list caching
List<Player>? _cachedPlayers;
Player? _cachedSelectedPlayer;
DateTime? _playersLastFetched;
// Player track cache (for smooth swipe transitions)
@@ -219,6 +222,26 @@ class CacheService {
_playersLastFetched = DateTime.now();
}
/// Get cached players list
List<Player>? getCachedPlayers() => _cachedPlayers;
/// Set cached players list
void setCachedPlayers(List<Player> players) {
_cachedPlayers = players;
_playersLastFetched = DateTime.now();
}
/// Get cached selected player
Player? getCachedSelectedPlayer() => _cachedSelectedPlayer;
/// Set cached selected player
void setCachedSelectedPlayer(Player? player) {
_cachedSelectedPlayer = player;
}
/// Check if we have cached players (for instant UI display on app resume)
bool get hasCachedPlayers => _cachedPlayers != null && _cachedPlayers!.isNotEmpty;
/// Get cached track for a player (used for smooth swipe transitions)
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
@@ -249,6 +272,8 @@ class CacheService {
void clearAll() {
invalidateHomeCache();
clearAllDetailCaches();
_cachedPlayers = null;
_cachedSelectedPlayer = null;
_playersLastFetched = null;
}
}
+16
View File
@@ -15,6 +15,7 @@ class SettingsService {
static const String _keyUseMaterialTheme = 'use_material_theme';
static const String _keyAdaptiveTheme = 'adaptive_theme';
static const String _keyCustomColor = 'custom_color';
static const String _keyLocale = 'locale';
static const String _keyLastFmApiKey = 'lastfm_api_key';
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
static const String _keyEnableLocalPlayback = 'enable_local_playback';
@@ -265,6 +266,21 @@ class SettingsService {
await prefs.setString(_keyCustomColor, color);
}
// Locale (null = system default)
static Future<String?> getLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keyLocale);
}
static Future<void> saveLocale(String? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale == null) {
await prefs.remove(_keyLocale);
} else {
await prefs.setString(_keyLocale, locale);
}
}
// Metadata API Keys
static Future<String?> getLastFmApiKey() async {
final prefs = await SharedPreferences.getInstance();
+10 -7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A reusable widget for displaying disconnected/offline state.
@@ -21,29 +22,31 @@ class DisconnectedState extends StatelessWidget {
});
/// Simple disconnected state without action button
factory DisconnectedState.simple() => const DisconnectedState(
title: 'Not connected to Music Assistant',
factory DisconnectedState.simple(BuildContext context) => DisconnectedState(
title: S.of(context)!.notConnected,
);
/// Disconnected state with settings button for navigation
factory DisconnectedState.withSettingsAction({
required BuildContext context,
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not connected to Music Assistant',
actionLabel: 'Configure Server',
title: S.of(context)!.notConnected,
actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded,
onAction: onSettings,
);
/// Full disconnected state with title, subtitle, and action (for home screen)
factory DisconnectedState.full({
required BuildContext context,
required VoidCallback onSettings,
}) =>
DisconnectedState(
title: 'Not Connected',
subtitle: 'Connect to your Music Assistant server to start listening',
actionLabel: 'Configure Server',
title: S.of(context)!.notConnectedTitle,
subtitle: S.of(context)!.connectHint,
actionLabel: S.of(context)!.configureServer,
actionIcon: Icons.settings_rounded,
onAction: onSettings,
iconSize: 80,
+57 -33
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A reusable empty state widget for displaying when no data is available.
@@ -21,48 +22,66 @@ class EmptyState extends StatelessWidget {
});
/// Creates an empty state for artists
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.person_outline_rounded,
message: 'No artists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
factory EmptyState.artists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.person_outline_rounded,
message: s.noArtistsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for albums
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.album_outlined,
message: 'No albums found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
factory EmptyState.albums({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.album_outlined,
message: s.noAlbumsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for tracks
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.music_note_outlined,
message: 'No tracks found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
factory EmptyState.tracks({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.music_note_outlined,
message: s.noTracksFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for playlists
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
icon: Icons.playlist_play_rounded,
message: 'No playlists found',
actionLabel: onRefresh != null ? 'Refresh' : null,
onAction: onRefresh,
);
factory EmptyState.playlists({VoidCallback? onRefresh, required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.playlist_play_rounded,
message: s.noPlaylistsFound,
actionLabel: onRefresh != null ? s.refresh : null,
onAction: onRefresh,
);
}
/// Creates an empty state for queue
factory EmptyState.queue() => const EmptyState(
icon: Icons.queue_music,
message: 'Queue is empty',
);
factory EmptyState.queue({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.queue_music,
message: s.queueIsEmpty,
);
}
/// Creates an empty state for search results
factory EmptyState.search() => const EmptyState(
icon: Icons.search_off_rounded,
message: 'No results found',
);
factory EmptyState.search({required BuildContext context}) {
final s = S.of(context)!;
return EmptyState(
icon: Icons.search_off_rounded,
message: s.noResultsFound,
);
}
/// Creates a custom empty state with title and subtitle
factory EmptyState.custom({
@@ -70,12 +89,14 @@ class EmptyState extends StatelessWidget {
required String title,
String? subtitle,
VoidCallback? onRefresh,
required BuildContext context,
}) =>
_CustomEmptyState(
icon: icon,
title: title,
subtitle: subtitle,
onRefresh: onRefresh,
context: context,
);
@override
@@ -126,17 +147,20 @@ class _CustomEmptyState extends EmptyState {
final String title;
final String? subtitle;
final VoidCallback? onRefresh;
final BuildContext context;
const _CustomEmptyState({
required super.icon,
required this.title,
this.subtitle,
this.onRefresh,
required this.context,
}) : super(message: title);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final s = S.of(context)!;
return Center(
child: Padding(
@@ -175,7 +199,7 @@ class _CustomEmptyState extends EmptyState {
ElevatedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh'),
label: Text(s.refresh),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
+9 -6
View File
@@ -10,6 +10,7 @@ import '../models/player.dart';
import '../services/animation_debugger.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../l10n/app_localizations.dart';
import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'volume_control.dart';
@@ -400,7 +401,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update favorite: $e'),
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
duration: const Duration(seconds: 2),
),
);
@@ -1216,6 +1217,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Must have peek data to render
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
_buildPeekContent(
context: context,
maProvider: maProvider,
peekPlayer: _peekPlayer,
peekImageUrl: _peekImageUrl,
@@ -1326,7 +1328,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
width: titleWidth,
child: Text(
maProvider.isPlayingAudiobook
? (maProvider.currentAudiobook?.authorsString ?? 'Unknown Author')
? (maProvider.currentAudiobook?.authorsString ?? S.of(context)!.unknownAuthor)
: currentTrack.artistsString,
style: TextStyle(
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
@@ -1387,7 +1389,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
const SizedBox(width: 6),
Text(
maProvider.currentAudioFormat ?? 'PCM Audio',
maProvider.currentAudioFormat ?? S.of(context)!.pcmAudio,
style: TextStyle(
color: primaryColor.withOpacity(0.7),
fontSize: 12,
@@ -1443,7 +1445,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error seeking: $e')),
SnackBar(content: Text(S.of(context)!.errorSeeking(e.toString()))),
);
}
} finally {
@@ -1711,6 +1713,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
/// Build the peek player content that slides in from the edge during drag
Widget _buildPeekContent({
required BuildContext context,
required MusicAssistantProvider maProvider,
required dynamic peekPlayer,
required String? peekImageUrl,
@@ -1750,8 +1753,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// Check if peek player has a track - if not, show device info instead
final hasTrack = _peekTrack != null && peekImageUrl != null;
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? 'Unknown');
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : 'Swipe to switch device';
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? S.of(context)!.unknown);
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : S.of(context)!.swipeToSwitchDevice;
return Stack(
children: [
+14 -13
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/theme_provider.dart';
@@ -206,26 +207,26 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
type: BottomNavigationBarType.fixed,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: 'Home',
icon: const Icon(Icons.home_outlined),
activeIcon: const Icon(Icons.home_rounded),
label: S.of(context)!.home,
),
BottomNavigationBarItem(
icon: Icon(Icons.library_music_outlined),
activeIcon: Icon(Icons.library_music_rounded),
label: 'Library',
icon: const Icon(Icons.library_music_outlined),
activeIcon: const Icon(Icons.library_music_rounded),
label: S.of(context)!.library,
),
BottomNavigationBarItem(
icon: Icon(Icons.search_rounded),
activeIcon: Icon(Icons.search_rounded),
label: 'Search',
icon: const Icon(Icons.search_rounded),
activeIcon: const Icon(Icons.search_rounded),
label: S.of(context)!.search,
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings_rounded),
label: 'Settings',
icon: const Icon(Icons.settings_outlined),
activeIcon: const Icon(Icons.settings_rounded),
label: S.of(context)!.settings,
),
],
),
+7 -5
View File
@@ -3,6 +3,7 @@ import '../../providers/music_assistant_provider.dart';
import '../../models/media_item.dart' show Audiobook, Chapter;
import '../../theme/design_tokens.dart';
import '../common/empty_state.dart';
import '../../l10n/app_localizations.dart';
/// Panel that displays audiobook chapters for navigation
class ChaptersPanel extends StatelessWidget {
@@ -58,7 +59,7 @@ class ChaptersPanel extends StatelessWidget {
),
const Spacer(),
Text(
'Chapters',
S.of(context)!.chapters,
style: TextStyle(
color: textColor,
fontSize: 18,
@@ -75,7 +76,7 @@ class ChaptersPanel extends StatelessWidget {
// Chapters list
Expanded(
child: chapters.isEmpty
? _buildEmptyState()
? _buildEmptyState(context)
: _buildChaptersList(chapters, currentChapterIndex),
),
],
@@ -83,11 +84,12 @@ class ChaptersPanel extends StatelessWidget {
);
}
Widget _buildEmptyState() {
Widget _buildEmptyState(BuildContext context) {
return EmptyState.custom(
context: context,
icon: Icons.bookmark_outline_rounded,
title: 'No chapters',
subtitle: 'This audiobook has no chapter information',
title: S.of(context)!.noChapters,
subtitle: S.of(context)!.noChapterInfo,
);
}
+5 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../theme/design_tokens.dart';
/// A compact device selector bar shown when no track is playing
@@ -52,7 +53,7 @@ class DeviceSelectorBar extends StatelessWidget {
children: [
// Peek player content (shows when dragging)
if (slideOffset.abs() > 0.01 && peekPlayer != null)
_buildPeekContent(),
_buildPeekContent(context),
// Current player content
Transform.translate(
@@ -84,7 +85,7 @@ class DeviceSelectorBar extends StatelessWidget {
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
@@ -106,7 +107,7 @@ class DeviceSelectorBar extends StatelessWidget {
}
/// Build peek player content that slides in from the edge
Widget _buildPeekContent() {
Widget _buildPeekContent(BuildContext context) {
final isFromRight = slideOffset < 0;
final peekProgress = slideOffset.abs();
@@ -144,7 +145,7 @@ class DeviceSelectorBar extends StatelessWidget {
),
if (hasMultiplePlayers)
Text(
'Swipe to switch device',
S.of(context)!.swipeToSwitchDevice,
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 14,
+3 -3
View File
@@ -70,7 +70,7 @@ class QueuePanel extends StatelessWidget {
child: isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: queue == null || queue!.items.isEmpty
? _buildEmptyState()
? _buildEmptyState(context)
: _buildQueueList(),
),
],
@@ -78,8 +78,8 @@ class QueuePanel extends StatelessWidget {
);
}
Widget _buildEmptyState() {
return EmptyState.queue();
Widget _buildEmptyState(BuildContext context) {
return EmptyState.queue(context: context);
}
Widget _buildQueueList() {
+6 -5
View File
@@ -4,6 +4,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart';
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
@@ -182,7 +183,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
child: currentPlayers.isEmpty
? Center(
child: Text(
'No players available',
S.of(context)!.noPlayersAvailable,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
),
)
@@ -304,12 +305,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
Expanded(
child: Text(
!player.available
? 'Unavailable'
? S.of(context)!.playerStateUnavailable
: !isOn
? 'Off'
? S.of(context)!.playerStateOff
: hasContent && playerTrack != null
? playerTrack.name
: 'Idle',
: S.of(context)!.playerStateIdle,
style: TextStyle(
color: player.available
? colorScheme.onSurfaceVariant
@@ -329,7 +330,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Selected',
S.of(context)!.playerSelected,
style: TextStyle(
color: colorScheme.primary,
fontSize: 10,
+5 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.3.0-beta+22
version: 2.3.2-beta+24
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -9,6 +9,9 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.19.0
# UI
material_design_icons_flutter: ^7.0.7296
@@ -54,6 +57,7 @@ dev_dependencies:
drift_dev: ^2.22.1
flutter:
generate: true
uses-material-design: true
assets: