Compare commits
54
Commits
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Generate code (Drift database)
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Generate localizations
|
||||
run: flutter gen-l10n
|
||||
|
||||
- name: Generate launcher icons
|
||||
run: flutter pub run flutter_launcher_icons
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
+27
-3
@@ -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
|
||||
@@ -167,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||
// If player is expanded, collapse it and consume the back gesture
|
||||
// Priority order: device list > expanded player > normal navigation
|
||||
|
||||
// Device list has highest priority - dismiss it first
|
||||
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
// Then check expanded player
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
return true; // We handled it, don't let Navigator process it
|
||||
}
|
||||
|
||||
// Let Navigator handle the back gesture normally
|
||||
return super.didPopRoute();
|
||||
}
|
||||
@@ -197,9 +211,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 +243,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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +458,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
try {
|
||||
await refreshPlayers();
|
||||
await _updatePlayerState();
|
||||
// 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');
|
||||
@@ -486,6 +529,76 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse server version into components for version comparisons
|
||||
/// Returns null if version cannot be parsed
|
||||
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
|
||||
final serverInfo = _api?.serverInfo;
|
||||
if (serverInfo == null) return null;
|
||||
|
||||
final versionStr = serverInfo['server_version'] as String?;
|
||||
if (versionStr == null) return null;
|
||||
|
||||
// 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 null;
|
||||
|
||||
return (
|
||||
major: int.parse(match.group(1)!),
|
||||
minor: int.parse(match.group(2)!),
|
||||
patch: int.parse(match.group(3)!),
|
||||
beta: match.group(4) != null ? int.parse(match.group(4)!) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
|
||||
bool _serverUsesSendspin() {
|
||||
final version = _parseServerVersion();
|
||||
if (version == null) return false;
|
||||
|
||||
final (:major, :minor, :patch, :beta) = version;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.8.0 (has built-in /sendspin proxy endpoint)
|
||||
/// The /sendspin proxy was added in MA 2.8.0 (PR #2840)
|
||||
/// MA 2.7.x does NOT have this proxy - users must expose port 8927 directly
|
||||
/// or manually configure reverse proxy routing to port 8927
|
||||
bool _serverHasSendspinProxy() {
|
||||
final version = _parseServerVersion();
|
||||
if (version == null) return false;
|
||||
|
||||
final (:major, :minor, :patch, :beta) = version;
|
||||
|
||||
// Compare with 2.8.0
|
||||
if (major > 2) return true;
|
||||
if (major < 2) return false;
|
||||
// major == 2
|
||||
if (minor > 8) return true;
|
||||
if (minor < 8) return false;
|
||||
// minor == 8, any 2.8.x version has the proxy
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get the server version string for logging
|
||||
String _getServerVersionString() {
|
||||
final serverInfo = _api?.serverInfo;
|
||||
return serverInfo?['server_version'] as String? ?? 'unknown';
|
||||
}
|
||||
|
||||
Future<void> _registerLocalPlayer() async {
|
||||
if (_api == null) return;
|
||||
|
||||
@@ -504,6 +617,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;
|
||||
|
||||
@@ -569,18 +702,30 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
|
||||
/// This is the replacement for builtin_player when that API is not available.
|
||||
///
|
||||
/// Connection strategy:
|
||||
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
|
||||
/// 2. Get local_ws_url from API and try that
|
||||
/// 3. WebRTC fallback as last resort (requires TURN servers)
|
||||
/// Connection strategy depends on MA version and network:
|
||||
/// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
|
||||
/// - MA 2.7.x: NO proxy, must either:
|
||||
/// - Use local IP with port 8927 exposed, OR
|
||||
/// - Manually configure reverse proxy to route /sendspin to port 8927
|
||||
Future<bool> _connectViaSendspin() async {
|
||||
if (_api == null || _serverUrl == null) return false;
|
||||
|
||||
try {
|
||||
final serverVersion = _getServerVersionString();
|
||||
final hasProxy = _serverHasSendspinProxy();
|
||||
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
|
||||
|
||||
// Initialize Sendspin service
|
||||
_sendspinService?.dispose();
|
||||
_sendspinService = SendspinService(_serverUrl!);
|
||||
|
||||
// Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
|
||||
final authToken = await SettingsService.getMaAuthToken();
|
||||
if (authToken != null) {
|
||||
_sendspinService!.setAuthToken(authToken);
|
||||
_logger.log('Sendspin: Auth token set for proxy authentication');
|
||||
}
|
||||
|
||||
// Initialize PCM audio player for raw audio streaming
|
||||
_pcmAudioPlayer?.dispose();
|
||||
_pcmAudioPlayer = PcmAudioPlayer();
|
||||
@@ -603,42 +748,59 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
|
||||
|
||||
final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_logger.log('Sendspin: Player ID: $playerId');
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
final isHttps = _serverUrl!.startsWith('https://') ||
|
||||
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':'));
|
||||
// Parse server URL to determine connection strategy
|
||||
final serverUri = Uri.parse(_serverUrl!.startsWith('http')
|
||||
? _serverUrl!
|
||||
: 'https://$_serverUrl');
|
||||
final isLocalIp = _isLocalNetworkHost(serverUri.host);
|
||||
final isHttps = serverUri.scheme == 'https' ||
|
||||
(!_serverUrl!.contains('://') && !isLocalIp);
|
||||
|
||||
// Strategy 1: For local IPs, connect directly to Sendspin port 8927
|
||||
if (isLocalIp) {
|
||||
final localSendspinUrl = 'ws://${serverUri.host}:8927/sendspin';
|
||||
_logger.log('Sendspin: Local network detected, trying direct connection: $localSendspinUrl');
|
||||
final connected = await _sendspinService!.connectWithUrl(localSendspinUrl);
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local network (port 8927)');
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: Local connection to port 8927 failed');
|
||||
|
||||
// For local IPs, also try the proxy path in case user has a local reverse proxy
|
||||
_logger.log('Sendspin: Trying local proxy fallback...');
|
||||
final localProxyUrl = 'ws://${serverUri.host}:${serverUri.hasPort ? serverUri.port : 8095}/sendspin';
|
||||
final proxyConnected = await _sendspinService!.connectWithUrl(localProxyUrl);
|
||||
if (proxyConnected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local proxy');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: For external/HTTPS servers, use the proxy at /sendspin
|
||||
if (isHttps || !isLocalIp) {
|
||||
if (hasProxy) {
|
||||
_logger.log('Sendspin: MA 2.8.0+ detected, using built-in /sendspin proxy');
|
||||
} else {
|
||||
_logger.log('Sendspin: MA 2.7.x detected, trying /sendspin (requires manual proxy config)');
|
||||
}
|
||||
|
||||
if (isHttps) {
|
||||
_logger.log('Sendspin: Server is HTTPS, trying external connection first');
|
||||
final connected = await _sendspinService!.connect();
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via external URL');
|
||||
_logger.log('✅ Sendspin: Connected via external proxy');
|
||||
return true;
|
||||
}
|
||||
_logger.log('⚠️ Sendspin: External connection failed, trying local URL');
|
||||
_logger.log('⚠️ Sendspin: External proxy connection failed');
|
||||
}
|
||||
|
||||
// Strategy 2: Get local_ws_url from API and try that
|
||||
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId);
|
||||
if (connectionInfo != null) {
|
||||
final localWsUrl = connectionInfo['local_ws_url'] as String?;
|
||||
if (localWsUrl != null) {
|
||||
_logger.log('Sendspin: Trying local WebSocket URL: $localWsUrl');
|
||||
final connected = await _sendspinService!.connectWithUrl(localWsUrl);
|
||||
if (connected) {
|
||||
_sendspinConnected = true;
|
||||
_logger.log('✅ Sendspin: Connected via local URL');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: WebRTC fallback (requires TURN servers from Nabucasa)
|
||||
// For now, just log that we'd need WebRTC - implementing full WebRTC
|
||||
// would require additional dependencies (flutter_webrtc)
|
||||
_logger.log('⚠️ Sendspin: WebRTC fallback not yet implemented');
|
||||
_logger.log('ℹ️ For external access, configure /sendspin route in your reverse proxy');
|
||||
// All strategies failed - provide version-specific guidance
|
||||
_logger.log('❌ Sendspin: All connection strategies failed');
|
||||
_logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
@@ -647,6 +809,29 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log troubleshooting guidance based on setup
|
||||
void _logSendspinTroubleshooting(bool isLocalIp, bool hasProxy, String serverVersion) {
|
||||
if (isLocalIp) {
|
||||
_logger.log('ℹ️ LOCAL IP SETUP: Add port 8927 to your Docker compose:');
|
||||
_logger.log(' ports:');
|
||||
_logger.log(' - "8095:8095"');
|
||||
_logger.log(' - "8927:8927" # Required for Sendspin');
|
||||
} else if (!hasProxy) {
|
||||
// MA 2.7.x without built-in proxy
|
||||
_logger.log('ℹ️ MA $serverVersion does not have built-in /sendspin proxy');
|
||||
_logger.log('ℹ️ OPTIONS:');
|
||||
_logger.log(' 1. Upgrade to Music Assistant 2.8.0+ (recommended)');
|
||||
_logger.log(' 2. Or add reverse proxy config for /sendspin → port 8927');
|
||||
_logger.log(' Traefik: PathPrefix(`/sendspin`) → service port 8927');
|
||||
_logger.log(' Nginx: location /sendspin { proxy_pass http://ma:8927; }');
|
||||
} else {
|
||||
// MA 2.8.0+ but still failing
|
||||
_logger.log('ℹ️ MA $serverVersion should have /sendspin proxy');
|
||||
_logger.log('ℹ️ Check that your reverse proxy forwards WebSocket connections');
|
||||
_logger.log(' Ensure Upgrade and Connection headers are passed through');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Sendspin play command
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
|
||||
_logger.log('🎵 Sendspin: Play command received');
|
||||
@@ -1044,6 +1229,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
if (currentMedia != null) {
|
||||
final mediaType = currentMedia['media_type'] as String?;
|
||||
|
||||
// Clear audiobook context when switching to music (track) playback
|
||||
if (mediaType == 'track' && _currentAudiobook != null) {
|
||||
_logger.log('📚 Media type changed to track - clearing audiobook context');
|
||||
clearCurrentAudiobook();
|
||||
}
|
||||
|
||||
if (mediaType != 'flow_stream') {
|
||||
final durationSecs = (currentMedia['duration'] as num?)?.toInt();
|
||||
final albumName = currentMedia['album'] as String?;
|
||||
@@ -1075,23 +1267,53 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
};
|
||||
}
|
||||
|
||||
// Parse artist from title if artist is missing but title contains " - "
|
||||
var trackTitle = currentMedia['title'] as String? ?? 'Unknown Track';
|
||||
var artistName = currentMedia['artist'] as String?;
|
||||
|
||||
if ((artistName == null || artistName == 'Unknown Artist') && trackTitle.contains(' - ')) {
|
||||
final parts = trackTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||
}
|
||||
}
|
||||
artistName ??= 'Unknown Artist';
|
||||
|
||||
final trackFromEvent = Track(
|
||||
itemId: currentMedia['queue_item_id'] as String? ?? '',
|
||||
provider: 'library',
|
||||
name: currentMedia['title'] as String? ?? 'Unknown Track',
|
||||
name: trackTitle,
|
||||
uri: currentMedia['uri'] as String?,
|
||||
duration: durationSecs != null ? Duration(seconds: durationSecs) : null,
|
||||
artists: [Artist(itemId: '', provider: 'library', name: currentMedia['artist'] as String? ?? 'Unknown Artist')],
|
||||
artists: [Artist(itemId: '', provider: 'library', name: artistName)],
|
||||
album: albumName != null ? Album(itemId: '', provider: 'library', name: albumName) : null,
|
||||
metadata: metadata,
|
||||
);
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId) {
|
||||
_currentTrack = trackFromEvent;
|
||||
// Only cache if we don't already have better data from queue
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||
final existingHasProperArtist = existingTrack != null &&
|
||||
existingTrack.artistsString != 'Unknown Artist' &&
|
||||
!existingTrack.name.contains(' - ');
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = metadata != null;
|
||||
|
||||
// Keep existing if it has proper artist OR has image that new one lacks
|
||||
final keepExisting = existingHasProperArtist || (existingHasImage && !newHasImage);
|
||||
|
||||
if (!keepExisting) {
|
||||
_cacheService.setCachedTrackForPlayer(playerId, trackFromEvent);
|
||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||
} else {
|
||||
_logger.log('📋 Skipped caching for $playerName - already have better data (artist: $existingHasProperArtist, image: $existingHasImage)');
|
||||
}
|
||||
|
||||
_logger.log('📋 Cached track for $playerName from player_updated: ${trackFromEvent.name}');
|
||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||
// Only update _currentTrack here if we don't have it yet (initial load)
|
||||
if (_selectedPlayer != null && playerId == _selectedPlayer!.playerId && _currentTrack == null) {
|
||||
_currentTrack = trackFromEvent;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1566,7 +1788,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');
|
||||
|
||||
@@ -1624,8 +1847,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);
|
||||
}
|
||||
@@ -1634,6 +1858,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
|
||||
@@ -1742,6 +1968,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
_preloadAdjacentPlayers();
|
||||
|
||||
// Immediately fetch fresh track data from queue to avoid showing stale cache
|
||||
// This is important when resuming app after being in background - cached data
|
||||
// from player_updated events may have incomplete artist info
|
||||
await _updatePlayerState();
|
||||
|
||||
if (!skipNotify) {
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -2008,6 +2239,26 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
await Future.wait(
|
||||
players.map((player) => _preloadPlayerTrack(player)),
|
||||
);
|
||||
|
||||
// After preloading, update _currentTrack from cache if it has better data
|
||||
// This fixes the issue where mini player shows wrong info but device list is correct
|
||||
if (_selectedPlayer != null && _currentTrack != null) {
|
||||
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
if (cachedTrack != null && cachedTrack.uri == _currentTrack!.uri) {
|
||||
final cachedHasImage = cachedTrack.metadata?['images'] != null;
|
||||
final currentHasImage = _currentTrack!.metadata?['images'] != null;
|
||||
final cachedHasArtist = cachedTrack.artistsString.isNotEmpty &&
|
||||
cachedTrack.artistsString != 'Unknown Artist';
|
||||
final currentHasArtist = _currentTrack!.artistsString.isNotEmpty &&
|
||||
_currentTrack!.artistsString != 'Unknown Artist';
|
||||
|
||||
if ((cachedHasImage && !currentHasImage) || (cachedHasArtist && !currentHasArtist)) {
|
||||
_currentTrack = cachedTrack;
|
||||
_logger.log('🎵 Updated currentTrack from cache with better metadata');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startPlayerStatePolling() {
|
||||
@@ -2082,12 +2333,56 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
final queue = await getQueue(_selectedPlayer!.playerId);
|
||||
|
||||
if (queue != null && queue.currentItem != null) {
|
||||
final queueTrack = queue.currentItem!.track;
|
||||
final trackChanged = _currentTrack == null ||
|
||||
_currentTrack!.uri != queue.currentItem!.track.uri ||
|
||||
_currentTrack!.name != queue.currentItem!.track.name;
|
||||
_currentTrack!.uri != queueTrack.uri ||
|
||||
_currentTrack!.name != queueTrack.name;
|
||||
|
||||
if (trackChanged) {
|
||||
_currentTrack = queue.currentItem!.track;
|
||||
// Check if cached track has better metadata (images, proper artist info)
|
||||
// This prevents losing album art and artist data when resuming the app
|
||||
final cachedTrack = _cacheService.getCachedTrackForPlayer(_selectedPlayer!.playerId);
|
||||
final cachedHasImage = cachedTrack?.metadata?['images'] != null;
|
||||
final queueHasImage = queueTrack.metadata?['images'] != null;
|
||||
// Also check for malformed artist names (e.g., "Artist - Title" format in name)
|
||||
final cachedHasProperArtist = cachedTrack?.artistsString.isNotEmpty == true &&
|
||||
cachedTrack?.artistsString != 'Unknown Artist' &&
|
||||
!cachedTrack!.name.contains(' - '); // Proper track doesn't have artist in name
|
||||
final queueHasProperArtist = queueTrack.artistsString.isNotEmpty &&
|
||||
queueTrack.artistsString != 'Unknown Artist' &&
|
||||
!queueTrack.name.contains(' - ');
|
||||
|
||||
// Also check if _currentTrack (set from cache in selectPlayer) has good data
|
||||
final currentHasImage = _currentTrack?.metadata?['images'] != null;
|
||||
final currentHasProperArtist = _currentTrack?.artistsString.isNotEmpty == true &&
|
||||
_currentTrack?.artistsString != 'Unknown Artist' &&
|
||||
!(_currentTrack?.name.contains(' - ') ?? false);
|
||||
|
||||
// Match by URI - name might differ if queue has malformed data
|
||||
final isSameTrackAsCached = cachedTrack?.uri == queueTrack.uri;
|
||||
final cachedHasBetterData = (cachedHasImage && !queueHasImage) ||
|
||||
(cachedHasProperArtist && !queueHasProperArtist);
|
||||
|
||||
// Queue track has bad metadata if it lacks image AND lacks proper artist
|
||||
final queueHasBadMetadata = !queueHasImage && !queueHasProperArtist;
|
||||
// Current track (from cache at selectPlayer) has good metadata
|
||||
final currentHasGoodMetadata = currentHasImage || currentHasProperArtist;
|
||||
|
||||
if (isSameTrackAsCached && cachedHasBetterData) {
|
||||
// Use cached track which has better metadata
|
||||
_currentTrack = cachedTrack;
|
||||
_logger.log('🎵 Using cached track with better metadata for ${cachedTrack!.name}');
|
||||
} else if (queueHasBadMetadata && currentHasGoodMetadata) {
|
||||
// Queue has bad data, but _currentTrack (set from cache) is good - keep it
|
||||
_logger.log('🎵 Keeping currentTrack - queue has bad metadata but current is good');
|
||||
// Don't change _currentTrack, keep the good data
|
||||
} else {
|
||||
_currentTrack = queueTrack;
|
||||
// Update cache if queue track has good metadata
|
||||
if (queueHasImage || queueHasProperArtist) {
|
||||
_cacheService.setCachedTrackForPlayer(_selectedPlayer!.playerId, queueTrack);
|
||||
}
|
||||
}
|
||||
stateChanged = true;
|
||||
|
||||
// Clear audiobook context if switched to a different media item
|
||||
@@ -2823,4 +3118,25 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_api?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a hostname is a local/private network address
|
||||
bool _isLocalNetworkHost(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.16.') ||
|
||||
host.startsWith('172.17.') ||
|
||||
host.startsWith('172.18.') ||
|
||||
host.startsWith('172.19.') ||
|
||||
host.startsWith('172.2') ||
|
||||
host.startsWith('172.30.') ||
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net'); // Tailscale - treat as local since it's a VPN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!)
|
||||
: '',
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
@@ -61,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
|
||||
// If player reveal (device list) is visible, dismiss it first (with animation)
|
||||
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||
return;
|
||||
}
|
||||
|
||||
// If global player is expanded, collapse it first
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
@@ -186,43 +188,47 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
return Stack(
|
||||
children: [
|
||||
// Connecting banner when showing cached data
|
||||
if (!isConnected && syncService.hasCache)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
// Main content
|
||||
RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
// Connecting banner overlay (doesn't affect layout)
|
||||
// Hide when we have cached players - UI is functional during background reconnect
|
||||
if (!isConnected && syncService.hasCache && !maProvider.hasCachedPlayers)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.9),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
S.of(context)!.connecting,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -296,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,
|
||||
);
|
||||
@@ -304,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,
|
||||
);
|
||||
@@ -312,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,
|
||||
);
|
||||
@@ -320,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,
|
||||
);
|
||||
@@ -328,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,
|
||||
);
|
||||
@@ -336,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,
|
||||
);
|
||||
@@ -344,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,
|
||||
);
|
||||
@@ -352,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,
|
||||
);
|
||||
@@ -360,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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'auth_strategy.dart';
|
||||
import 'no_auth_strategy.dart';
|
||||
@@ -86,6 +87,22 @@ class AuthManager {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// Check if a hostname is a local/private IP address
|
||||
bool _isLocalIpAddress(String host) {
|
||||
return host.startsWith('192.168.') ||
|
||||
host.startsWith('10.') ||
|
||||
host.startsWith('172.16.') ||
|
||||
host.startsWith('172.17.') ||
|
||||
host.startsWith('172.18.') ||
|
||||
host.startsWith('172.19.') ||
|
||||
host.startsWith('172.2') ||
|
||||
host.startsWith('172.30.') ||
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local');
|
||||
}
|
||||
|
||||
/// Try to detect auth strategy for a given URL
|
||||
/// Returns the strategy if successful, null if connection failed
|
||||
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||
@@ -187,15 +204,50 @@ class AuthManager {
|
||||
|
||||
_logger.log('Checking MA API at: $apiUrl');
|
||||
|
||||
// Try to get server info - this tells us if auth is required
|
||||
// Note: The command is just 'info', not 'server/info'
|
||||
final response = await http.post(
|
||||
apiUrl,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command': 'info',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
// Use IOClient to control redirect behavior for local IPs
|
||||
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||
http.Response response;
|
||||
|
||||
if (isLocalIp) {
|
||||
// For local IPs, don't follow redirects - handle them explicitly
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.postUrl(apiUrl);
|
||||
request.followRedirects = false;
|
||||
request.headers.set('Content-Type', 'application/json');
|
||||
request.write(jsonEncode({'command': 'info'}));
|
||||
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
|
||||
|
||||
// Check for redirect to HTTPS
|
||||
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
|
||||
final location = ioResponse.headers.value('location');
|
||||
_logger.log('Local IP redirects to: $location');
|
||||
if (location != null && location.startsWith('https://')) {
|
||||
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
|
||||
// Don't follow the redirect - it will likely fail with cert error
|
||||
// Instead, return null to trigger fallback logic
|
||||
client.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final body = await ioResponse.transform(utf8.decoder).join();
|
||||
response = http.Response(body, ioResponse.statusCode);
|
||||
client.close();
|
||||
} catch (e) {
|
||||
client.close();
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
// For non-local URLs, use standard http.post which follows redirects
|
||||
response = await http.post(
|
||||
apiUrl,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'command': 'info',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
_logger.log('MA API response: ${response.statusCode}');
|
||||
_logger.log('MA API body: ${response.body}');
|
||||
@@ -255,21 +307,47 @@ class AuthManager {
|
||||
/// Test if server is accessible without authentication
|
||||
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
||||
try {
|
||||
// Test root endpoint - Music Assistant doesn't have /api/info
|
||||
final testUrl = serverUrl;
|
||||
_logger.log('No-auth test URL: $testUrl');
|
||||
final uri = Uri.parse(serverUrl);
|
||||
_logger.log('No-auth test URL: $serverUrl');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse(testUrl),
|
||||
// Don't follow redirects - we want to see if server requires auth
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||
int statusCode;
|
||||
|
||||
_logger.log('No-auth status: ${response.statusCode}');
|
||||
if (isLocalIp) {
|
||||
// For local IPs, don't follow redirects to avoid HTTPS cert issues
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
request.followRedirects = false;
|
||||
final response = await request.close().timeout(const Duration(seconds: 10));
|
||||
statusCode = response.statusCode;
|
||||
|
||||
// Check for redirect to HTTPS
|
||||
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
|
||||
final location = response.headers.value('location');
|
||||
if (location != null && location.startsWith('https://')) {
|
||||
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
|
||||
client.close();
|
||||
return false; // Can't connect - would need HTTPS which has cert issues
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
} catch (e) {
|
||||
client.close();
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
// For non-local URLs, use standard http.get
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 10));
|
||||
statusCode = response.statusCode;
|
||||
}
|
||||
|
||||
_logger.log('No-auth status: $statusCode');
|
||||
|
||||
// 200 = no auth required
|
||||
// 302/307 = redirect (probably to auth)
|
||||
// 302/307/308 = redirect (probably to auth or HTTPS)
|
||||
// 401 = auth required
|
||||
if (response.statusCode == 200) {
|
||||
if (statusCode == 200) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2302,89 +2302,15 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['sendspin_player']!.stream;
|
||||
}
|
||||
|
||||
/// Get Sendspin connection info for a player
|
||||
/// Returns connection details including:
|
||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
||||
/// - player_id: The player's ID
|
||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/connection_info',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
|
||||
final result = response['result'] as Map<String, dynamic>?;
|
||||
if (result != null) {
|
||||
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
|
||||
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error getting connection info: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC offer to Sendspin server
|
||||
/// Used when establishing WebRTC connection as fallback
|
||||
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'sendspin/webrtc_offer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
|
||||
return response['result'] as Map<String, dynamic>?;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC offer: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebRTC answer to Sendspin server
|
||||
Future<void> sendspinAnswer(String playerId, String sdp) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
|
||||
|
||||
await _sendCommand(
|
||||
'sendspin/webrtc_answer',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'sdp': sdp,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending WebRTC answer: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Send ICE candidate to Sendspin server
|
||||
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'sendspin/ice_candidate',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'candidate': candidate,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending ICE candidate: $e');
|
||||
// Don't rethrow - ICE candidate failures are common and non-fatal
|
||||
}
|
||||
}
|
||||
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
|
||||
// - getSendspinConnectionInfo (sendspin/connection_info)
|
||||
// - sendspinOffer (sendspin/webrtc_offer)
|
||||
// - sendspinAnswer (sendspin/webrtc_answer)
|
||||
// - sendspinIceCandidate (sendspin/ice_candidate)
|
||||
//
|
||||
// Sendspin connection is handled directly via WebSocket to:
|
||||
// - Local: ws://{server-ip}:8927/sendspin
|
||||
// - External: wss://{server}/sendspin (via MA's proxy with auth)
|
||||
|
||||
/// Update Sendspin player state
|
||||
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
||||
|
||||
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
|
||||
final List<Uint8List> _audioBuffer = [];
|
||||
bool _isFeeding = false;
|
||||
bool _isStarted = false;
|
||||
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||
|
||||
// Error callback for operation failures
|
||||
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
|
||||
|
||||
/// Handle incoming audio data from the stream
|
||||
void _onAudioData(Uint8List audioData) {
|
||||
// Ignore data if in error state or if in transitional/paused state
|
||||
// Ignore data if in error state
|
||||
if (_state == PcmPlayerState.error) return;
|
||||
|
||||
// Handle audio arriving during paused/transitional states
|
||||
// This can happen when audio frames arrive before stream/start message (race condition)
|
||||
// We need to auto-recover in this case
|
||||
if (_shouldBlockFeeding) {
|
||||
// Silently ignore audio data when paused or transitioning
|
||||
// This prevents buffer accumulation during pause and any race conditions
|
||||
// If we're paused and audio is arriving, this is likely a new stream
|
||||
// starting before the stream/start message. Queue for auto-recovery.
|
||||
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||
_isAutoRecovering = true;
|
||||
_audioBuffer.add(audioData);
|
||||
|
||||
// Trigger async recovery
|
||||
_autoRecoverFromPause();
|
||||
return;
|
||||
}
|
||||
|
||||
// If already recovering or in transitional state, buffer the data
|
||||
if (_isAutoRecovering) {
|
||||
_audioBuffer.add(audioData);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other blocking states (pausing, stopping, resuming), ignore
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
|
||||
_startPlayback();
|
||||
}
|
||||
|
||||
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
|
||||
// The provider should explicitly call play() when ready to resume.
|
||||
|
||||
// Feed data if not currently feeding
|
||||
if (!_isFeeding && _isStarted) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-recover from paused state when audio arrives unexpectedly
|
||||
Future<void> _autoRecoverFromPause() async {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
|
||||
|
||||
// Start feeding buffered data
|
||||
_isAutoRecovering = false;
|
||||
if (_audioBuffer.isNotEmpty && !_isFeeding) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
|
||||
_isAutoRecovering = false;
|
||||
_state = PcmPlayerState.error;
|
||||
_emitError(PcmPlayerError.resumeFailed, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Start audio playback
|
||||
Future<void> _startPlayback() async {
|
||||
if (_isStarted) return;
|
||||
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
|
||||
|
||||
// Check state again before the async feed call
|
||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
try {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (feedError) {
|
||||
// Auto-recover from "must call setup first" error
|
||||
// This happens when audio frames arrive before stream/start message
|
||||
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
|
||||
_isAutoRecovering = true;
|
||||
|
||||
try {
|
||||
// Re-initialize the native player
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
await pcm.FlutterPcmSound.start();
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery successful');
|
||||
|
||||
// Retry the feed with the current chunk
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
} catch (recoveryError) {
|
||||
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
|
||||
_isAutoRecovering = false;
|
||||
rethrow;
|
||||
}
|
||||
_isAutoRecovering = false;
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't update stats if we got paused during the feed
|
||||
if (!_shouldBlockFeeding) {
|
||||
|
||||
@@ -84,8 +84,16 @@ class SendspinService {
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Auth token for proxy authentication (MA 2.7.1+)
|
||||
String? _authToken;
|
||||
|
||||
SendspinService(this.serverUrl);
|
||||
|
||||
/// Set the MA auth token for proxy authentication
|
||||
void setAuthToken(String? token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
/// Initialize and connect to Sendspin server
|
||||
/// Uses persistent player ID and username-based naming
|
||||
Future<bool> connect() async {
|
||||
@@ -105,18 +113,18 @@ class SendspinService {
|
||||
bool connected = false;
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
|
||||
if (_isHttpsServer()) {
|
||||
final externalUrl = _buildExternalSendspinUrl();
|
||||
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||
}
|
||||
|
||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
||||
// Note: We would need local_ws_url from getSendspinConnectionInfo API
|
||||
// For now, skip this step and fall back to WebRTC (handled by provider)
|
||||
// Note: Local network connections (ws://ip:8927/sendspin) are handled
|
||||
// by the provider calling connectWithUrl() directly, not by this method.
|
||||
|
||||
if (!connected) {
|
||||
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
|
||||
_logger.log('Sendspin: External proxy connection failed');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
@@ -158,10 +166,11 @@ class SendspinService {
|
||||
}
|
||||
|
||||
/// Attempt to connect to a specific WebSocket URL
|
||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
|
||||
/// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
|
||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
|
||||
try {
|
||||
// Connect to the base URL without query params - we send player info in hello message
|
||||
_logger.log('Sendspin: Connecting to $url');
|
||||
_logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
|
||||
|
||||
// Create WebSocket connection
|
||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||
@@ -176,6 +185,29 @@ class SendspinService {
|
||||
onDone: _handleDone,
|
||||
);
|
||||
|
||||
// If using proxy auth (MA 2.7.1+), authenticate first
|
||||
if (useProxyAuth && _authToken != null) {
|
||||
_logger.log('Sendspin: Sending proxy auth with token');
|
||||
_sendMessage({
|
||||
'type': 'auth',
|
||||
'token': _authToken,
|
||||
}, allowDuringHandshake: true);
|
||||
|
||||
// Wait for auth_ok response
|
||||
final authOk = await _waitForAuth().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
);
|
||||
|
||||
if (!authOk) {
|
||||
_logger.log('Sendspin: Proxy authentication failed or timed out');
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
return false;
|
||||
}
|
||||
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
|
||||
}
|
||||
|
||||
// Send client/hello message immediately after connecting
|
||||
// This is required by the Sendspin protocol - server waits for this
|
||||
// Message format follows aiosendspin protocol spec
|
||||
@@ -228,12 +260,18 @@ class SendspinService {
|
||||
|
||||
/// Wait for server acknowledgment after connection
|
||||
Completer<bool>? _ackCompleter;
|
||||
Completer<bool>? _authCompleter;
|
||||
|
||||
Future<bool> _waitForAck() async {
|
||||
_ackCompleter = Completer<bool>();
|
||||
return _ackCompleter!.future;
|
||||
}
|
||||
|
||||
Future<bool> _waitForAuth() async {
|
||||
_authCompleter = Completer<bool>();
|
||||
return _authCompleter!.future;
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||
void _handleMessage(dynamic message) {
|
||||
@@ -310,6 +348,14 @@ class SendspinService {
|
||||
onStreamEnd?.call();
|
||||
break;
|
||||
|
||||
case 'auth_ok':
|
||||
// Proxy authentication successful (MA 2.7.1+)
|
||||
_logger.log('Sendspin: Proxy authentication successful');
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ack':
|
||||
case 'connected':
|
||||
case 'registered':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -24,9 +25,17 @@ class ExpandablePlayer extends StatefulWidget {
|
||||
/// Slide offset for hiding the mini player (0.0 = visible, 1.0 = hidden below screen)
|
||||
final double slideOffset;
|
||||
|
||||
/// Bounce offset for the player reveal animation (moves mini player down slightly)
|
||||
final double bounceOffset;
|
||||
|
||||
/// Callback when swipe-down gesture triggers player reveal
|
||||
final VoidCallback? onRevealPlayers;
|
||||
|
||||
const ExpandablePlayer({
|
||||
super.key,
|
||||
this.slideOffset = 0.0,
|
||||
this.bounceOffset = 0.0,
|
||||
this.onRevealPlayers,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -90,6 +99,18 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
String? _lastMeasuredTrackName;
|
||||
double? _lastMeasuredTitleWidth;
|
||||
|
||||
// Gesture-driven expansion state
|
||||
bool _isVerticalDragging = false;
|
||||
double _dragStartY = 0;
|
||||
double _dragStartValue = 0;
|
||||
|
||||
// Maximum drag distance for full expand/collapse
|
||||
static const double _maxDragDistance = 300.0;
|
||||
|
||||
// Thresholds for committing expand/collapse
|
||||
static const double _commitPositionThreshold = 0.3; // 30% progress
|
||||
static const double _commitVelocityThreshold = 800.0; // px/s
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -97,6 +118,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Simple curved animation - performant and smooth
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -181,23 +204,94 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
AnimationDebugger.recordFrame(_controller.value);
|
||||
}
|
||||
|
||||
// Animation durations - asymmetric for snappier collapse
|
||||
static const Duration _expandDuration = Duration(milliseconds: 280);
|
||||
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
||||
|
||||
void expand() {
|
||||
if (_isVerticalDragging) return;
|
||||
AnimationDebugger.startSession('playerExpand');
|
||||
_controller.duration = _expandDuration;
|
||||
_controller.forward().then((_) {
|
||||
AnimationDebugger.endSession();
|
||||
});
|
||||
}
|
||||
|
||||
void collapse() {
|
||||
if (_isVerticalDragging) return;
|
||||
AnimationDebugger.startSession('playerCollapse');
|
||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||
// during Android's predictive back gesture
|
||||
_queuePanelController.value = 0;
|
||||
_controller.duration = _collapseDuration;
|
||||
_controller.reverse().then((_) {
|
||||
AnimationDebugger.endSession();
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle vertical drag start - begin gesture-driven expansion
|
||||
void _handleVerticalDragStart(DragStartDetails details) {
|
||||
_isVerticalDragging = true;
|
||||
_dragStartY = details.globalPosition.dy;
|
||||
_dragStartValue = _controller.value;
|
||||
_controller.stop(); // Stop any running animation
|
||||
}
|
||||
|
||||
/// Handle vertical drag update - finger tracking
|
||||
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isVerticalDragging) return;
|
||||
|
||||
final dragDelta = _dragStartY - details.globalPosition.dy;
|
||||
// Swipe up = positive delta = expand (increase value)
|
||||
// Swipe down = negative delta = collapse (decrease value)
|
||||
final normalizedDelta = dragDelta / _maxDragDistance;
|
||||
final newValue = (_dragStartValue + normalizedDelta).clamp(0.0, 1.0);
|
||||
|
||||
_controller.value = newValue;
|
||||
}
|
||||
|
||||
/// Handle vertical drag end - decide to commit or snap back
|
||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||
if (!_isVerticalDragging) return;
|
||||
_isVerticalDragging = false;
|
||||
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
final currentValue = _controller.value;
|
||||
|
||||
// Determine direction based on velocity first, then position
|
||||
bool shouldExpand;
|
||||
if (velocity.abs() > _commitVelocityThreshold) {
|
||||
// High velocity: use velocity direction (negative = swipe up = expand)
|
||||
shouldExpand = velocity < 0;
|
||||
} else if (currentValue > _commitPositionThreshold && currentValue < (1 - _commitPositionThreshold)) {
|
||||
// In the middle zone: use velocity direction if any, otherwise snap to nearest
|
||||
shouldExpand = velocity < 0 || (velocity == 0 && currentValue > 0.5);
|
||||
} else {
|
||||
// Near edges: commit to nearest endpoint
|
||||
shouldExpand = currentValue > 0.5;
|
||||
}
|
||||
|
||||
// Animate to target with appropriate duration
|
||||
if (shouldExpand) {
|
||||
if (currentValue < 1.0) {
|
||||
AnimationDebugger.startSession('playerExpand');
|
||||
_controller.duration = _expandDuration;
|
||||
_controller.forward().then((_) {
|
||||
AnimationDebugger.endSession();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (currentValue > 0.0) {
|
||||
AnimationDebugger.startSession('playerCollapse');
|
||||
_queuePanelController.value = 0;
|
||||
_controller.duration = _collapseDuration;
|
||||
_controller.reverse().then((_) {
|
||||
AnimationDebugger.endSession();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get isExpanded => _controller.value > 0.5;
|
||||
|
||||
double get expansionProgress => _controller.value;
|
||||
@@ -400,7 +494,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),
|
||||
),
|
||||
);
|
||||
@@ -849,9 +943,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final bottomOffset = bottomNavSpace + _collapsedMargin;
|
||||
final width = screenSize.width - (_collapsedMargin * 2);
|
||||
|
||||
// Apply slide offset for hiding
|
||||
// Apply slide offset for hiding and bounce offset for reveal animation
|
||||
final slideDownAmount = widget.slideOffset * (_collapsedHeight + bottomOffset + 20);
|
||||
final adjustedBottomOffset = bottomOffset - slideDownAmount;
|
||||
final bounceDownAmount = widget.bounceOffset;
|
||||
final adjustedBottomOffset = bottomOffset - slideDownAmount - bounceDownAmount;
|
||||
|
||||
final availablePlayers = _getAvailablePlayersSorted(maProvider);
|
||||
final hasMultiplePlayers = availablePlayers.length > 1;
|
||||
@@ -860,7 +955,13 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
left: _collapsedMargin,
|
||||
right: _collapsedMargin,
|
||||
bottom: adjustedBottomOffset,
|
||||
child: DeviceSelectorBar(
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! > 5 && widget.onRevealPlayers != null) {
|
||||
widget.onRevealPlayers!();
|
||||
}
|
||||
},
|
||||
child: DeviceSelectorBar(
|
||||
selectedPlayer: selectedPlayer,
|
||||
peekPlayer: _peekPlayer,
|
||||
hasMultiplePlayers: hasMultiplePlayers,
|
||||
@@ -896,6 +997,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_handleHorizontalDragEnd(details, maProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -953,11 +1055,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final expandedHeight = screenSize.height - bottomNavSpace;
|
||||
|
||||
// Apply slide offset to hide mini player (slides down off-screen)
|
||||
// Only apply when collapsed (t == 0), don't affect expanded state
|
||||
// Apply bounce offset for reveal animation (small downward movement)
|
||||
// Smoothly fade out offsets as animation progresses (continuous, no discontinuity)
|
||||
final slideDownAmount = widget.slideOffset * (_collapsedHeight + collapsedBottomOffset + 20);
|
||||
final slideAdjustedBottomOffset = t < 0.1
|
||||
? collapsedBottomOffset - slideDownAmount
|
||||
: _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
|
||||
final bounceDownAmount = widget.bounceOffset;
|
||||
// Fade out slide/bounce effects smoothly over first 15% of animation
|
||||
final offsetFade = (1.0 - (t / 0.15)).clamp(0.0, 1.0);
|
||||
final baseBottomOffset = _lerpDouble(collapsedBottomOffset, expandedBottomOffset, t);
|
||||
final slideAdjustedBottomOffset = baseBottomOffset - (slideDownAmount + bounceDownAmount) * offsetFade;
|
||||
|
||||
final collapsedWidth = screenSize.width - (_collapsedMargin * 2);
|
||||
final width = _lerpDouble(collapsedWidth, screenSize.width, t);
|
||||
@@ -1001,7 +1106,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Artist: medium weight, secondary (18px expanded, was 16px)
|
||||
// Album: light, tertiary (15px expanded, was 13px)
|
||||
final titleFontSize = _lerpDouble(16.0, 24.0, t);
|
||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // Increased from 16 to 18
|
||||
final artistFontSize = _lerpDouble(14.0, 18.0, t); // 14px collapsed, 18px expanded
|
||||
|
||||
final collapsedTitleLeft = _collapsedArtSize + 8; // Reduced from 12 to 8 (4px less)
|
||||
final expandedTitleLeft = contentPadding;
|
||||
@@ -1009,7 +1114,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
||||
|
||||
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
|
||||
// Controls: 36 (prev) + 34 (play) + 36 (next) + 8 (right margin) = 114px from widget right
|
||||
// For 8px gap: text ends at widgetWidth - 114 - 8 = widgetWidth - 122
|
||||
// Note: collapsedWidth = screenSize.width - 24 (12px margin each side)
|
||||
final collapsedTitleWidth = collapsedWidth - collapsedTitleLeft - 122;
|
||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||
final titleWidth = _lerpDouble(collapsedTitleWidth, expandedTitleWidth, t);
|
||||
|
||||
@@ -1098,7 +1206,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final selectedPlayerId = selectedPlayer.playerId;
|
||||
|
||||
// Calculate slide offset for mini player content (only when collapsed)
|
||||
final miniPlayerSlideOffset = t < 0.1 ? _slideOffset * collapsedWidth : 0.0;
|
||||
// Smoothly fade out horizontal slide offset (for device switching) as animation progresses
|
||||
final miniPlayerSlideOffset = _slideOffset * collapsedWidth * offsetFade;
|
||||
|
||||
return Positioned(
|
||||
left: horizontalMargin,
|
||||
@@ -1109,13 +1218,47 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
behavior: HitTestBehavior.translucent,
|
||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||
onTap: isExpanded ? null : expand,
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
||||
expand();
|
||||
} else if (details.primaryDelta! > 10 && isExpanded && !isQueuePanelOpen) {
|
||||
collapse();
|
||||
onVerticalDragStart: (details) {
|
||||
// For expanded player or queue panel: start tracking immediately
|
||||
// For collapsed player: defer decision until we know swipe direction
|
||||
if (isExpanded || isQueuePanelOpen) {
|
||||
_handleVerticalDragStart(details);
|
||||
}
|
||||
},
|
||||
onVerticalDragUpdate: (details) {
|
||||
final delta = details.primaryDelta ?? 0;
|
||||
|
||||
// Handle queue panel close
|
||||
if (isQueuePanelOpen && delta > 0) {
|
||||
_toggleQueuePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapsed + not yet tracking: decide based on direction
|
||||
if (!isExpanded && !_isVerticalDragging) {
|
||||
if (delta > 5 && widget.onRevealPlayers != null) {
|
||||
// Swipe down → show player reveal
|
||||
widget.onRevealPlayers!();
|
||||
return;
|
||||
} else if (delta < -5) {
|
||||
// Swipe up → start expand tracking
|
||||
_handleVerticalDragStart(DragStartDetails(
|
||||
globalPosition: details.globalPosition,
|
||||
localPosition: details.localPosition,
|
||||
));
|
||||
} else {
|
||||
// Not enough movement yet, wait for more
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gesture-driven expand/collapse
|
||||
_handleVerticalDragUpdate(details);
|
||||
},
|
||||
onVerticalDragEnd: (details) {
|
||||
// Finish gesture-driven expansion
|
||||
_handleVerticalDragEnd(details);
|
||||
},
|
||||
onHorizontalDragStart: (details) {
|
||||
_horizontalDragStartX = details.globalPosition.dx;
|
||||
},
|
||||
@@ -1213,6 +1356,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,
|
||||
@@ -1304,6 +1448,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: t > 0.5 ? 2 : 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -1322,7 +1467,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),
|
||||
@@ -1331,6 +1476,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||
maxLines: 1,
|
||||
softWrap: t > 0.5, // false in collapsed to ensure ellipsis truncation
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -1382,7 +1528,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,
|
||||
@@ -1438,7 +1584,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 {
|
||||
@@ -1706,6 +1852,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,
|
||||
@@ -1745,8 +1892,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: [
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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';
|
||||
import 'expandable_player.dart';
|
||||
import 'player/player_reveal_overlay.dart';
|
||||
|
||||
/// Cached color with contrast adjustment
|
||||
/// Avoids expensive HSL conversions during scroll
|
||||
@@ -110,13 +113,48 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
static void showPlayer() {
|
||||
_overlayStateKey.currentState?._setHidden(false);
|
||||
}
|
||||
|
||||
/// Show the player reveal overlay with bounce animation
|
||||
static void showPlayerReveal() {
|
||||
_overlayStateKey.currentState?._showPlayerReveal();
|
||||
}
|
||||
|
||||
/// Hide the player reveal overlay (no animation - used as callback from overlay)
|
||||
static void hidePlayerReveal() {
|
||||
_overlayStateKey.currentState?._hidePlayerReveal();
|
||||
}
|
||||
|
||||
/// Dismiss the player reveal overlay with animation (for back gesture)
|
||||
static void dismissPlayerReveal() {
|
||||
_overlayStateKey.currentState?._dismissPlayerReveal();
|
||||
}
|
||||
|
||||
/// Trigger the bounce animation on mini player (called when overlay dismiss starts)
|
||||
static void triggerBounce() {
|
||||
_overlayStateKey.currentState?._triggerBounce();
|
||||
}
|
||||
|
||||
/// Check if the player reveal is currently visible
|
||||
static bool get isPlayerRevealVisible =>
|
||||
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
||||
}
|
||||
|
||||
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _slideController;
|
||||
late Animation<double> _slideAnimation;
|
||||
|
||||
// Controller for player reveal animation with bounce
|
||||
late AnimationController _bounceController;
|
||||
late Animation<double> _bounceAnimation;
|
||||
|
||||
// State for player reveal overlay
|
||||
bool _isRevealVisible = false;
|
||||
double _bounceOffset = 0.0;
|
||||
|
||||
// Key for the reveal overlay
|
||||
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -129,11 +167,31 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
// Bounce animation - quick dip down then back up
|
||||
_bounceController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_bounceAnimation = CurvedAnimation(
|
||||
parent: _bounceController,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
|
||||
_bounceController.addListener(() {
|
||||
setState(() {
|
||||
// Quick dip: goes down 10px at peak (0.5), then back to 0
|
||||
final t = _bounceAnimation.value;
|
||||
// Sine curve: 0 -> 1 -> 0 as t goes 0 -> 0.5 -> 1
|
||||
_bounceOffset = 10.0 * (t < 0.5 ? t * 2 : (1.0 - t) * 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideController.dispose();
|
||||
_bounceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -145,12 +203,55 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
}
|
||||
}
|
||||
|
||||
void _showPlayerReveal() {
|
||||
if (_isRevealVisible) return;
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
HapticFeedback.mediumImpact();
|
||||
_bounceController.reset();
|
||||
setState(() {
|
||||
_isRevealVisible = true;
|
||||
});
|
||||
_bounceController.forward();
|
||||
}
|
||||
|
||||
void _hidePlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
setState(() {
|
||||
_isRevealVisible = false;
|
||||
_bounceOffset = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
|
||||
void _dismissPlayerReveal() {
|
||||
if (!_isRevealVisible) return;
|
||||
// Call the overlay's dismiss method which has the slide animation
|
||||
// Note: dismiss() triggers the bounce animation internally
|
||||
_revealKey.currentState?.dismiss();
|
||||
}
|
||||
|
||||
/// Trigger bounce animation on mini player
|
||||
void _triggerBounce() {
|
||||
_bounceController.reset();
|
||||
_bounceController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Stack(
|
||||
// Handle back gesture at top level - dismiss device list if visible
|
||||
return PopScope(
|
||||
canPop: !_isRevealVisible,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isRevealVisible) {
|
||||
_dismissPlayerReveal();
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// The main app content (Navigator, screens, etc.)
|
||||
// Add bottom padding to account for bottom nav + mini player
|
||||
@@ -206,26 +307,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -258,7 +359,16 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
},
|
||||
),
|
||||
),
|
||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
||||
// Player reveal overlay - renders BELOW mini player so cards slide behind it
|
||||
if (_isRevealVisible)
|
||||
PlayerRevealOverlay(
|
||||
key: _revealKey,
|
||||
onDismiss: _hidePlayerReveal,
|
||||
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||
miniPlayerHeight: 64,
|
||||
),
|
||||
|
||||
// Global player overlay - renders ON TOP so cards slide behind it
|
||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
||||
selector: (_, provider) => (
|
||||
@@ -278,12 +388,15 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
return ExpandablePlayer(
|
||||
key: globalPlayerKey,
|
||||
slideOffset: _slideAnimation.value,
|
||||
bounceOffset: _bounceOffset,
|
||||
onRevealPlayers: _showPlayerReveal,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// A compact device selector bar shown when no track is playing
|
||||
class DeviceSelectorBar extends StatelessWidget {
|
||||
@@ -52,22 +52,31 @@ 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(
|
||||
offset: Offset(slideOffset * width, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(selectedPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
child: Row(
|
||||
children: [
|
||||
// Speaker icon - same size as album art for consistent text alignment
|
||||
SizedBox(
|
||||
width: height,
|
||||
height: height,
|
||||
child: Container(
|
||||
color: Color.lerp(backgroundColor, Colors.black, 0.15),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_getPlayerIcon(selectedPlayer.name),
|
||||
color: textColor.withOpacity(0.4),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.hGap12,
|
||||
Expanded(
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -84,17 +93,17 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
),
|
||||
if (hasMultiplePlayers)
|
||||
Text(
|
||||
'Swipe to switch device',
|
||||
S.of(context)!.swipeToSwitchDevice,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -106,7 +115,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();
|
||||
|
||||
@@ -117,17 +126,26 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(peekBaseOffset, 0),
|
||||
child: Padding(
|
||||
padding: Spacing.paddingH16,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlayerIcon(peekPlayer.name),
|
||||
color: textColor,
|
||||
size: IconSizes.md,
|
||||
child: Row(
|
||||
children: [
|
||||
// Speaker icon - same size as album art for consistent text alignment
|
||||
SizedBox(
|
||||
width: height,
|
||||
height: height,
|
||||
child: Container(
|
||||
color: Color.lerp(backgroundColor, Colors.black, 0.15),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_getPlayerIcon(peekPlayer.name),
|
||||
color: textColor.withOpacity(0.4),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
Spacing.hGap12,
|
||||
Expanded(
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -144,17 +162,17 @@ class DeviceSelectorBar extends StatelessWidget {
|
||||
),
|
||||
if (hasMultiplePlayers)
|
||||
Text(
|
||||
'Swipe to switch device',
|
||||
S.of(context)!.swipeToSwitchDevice,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// A player card that matches the mini player's visual style.
|
||||
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||
class PlayerCard extends StatelessWidget {
|
||||
final dynamic player;
|
||||
final dynamic trackInfo;
|
||||
final String? albumArtUrl;
|
||||
final bool isSelected;
|
||||
final bool isPlaying;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onPlayPause;
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
|
||||
const PlayerCard({
|
||||
super.key,
|
||||
required this.player,
|
||||
this.trackInfo,
|
||||
this.albumArtUrl,
|
||||
required this.isSelected,
|
||||
required this.isPlaying,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
required this.onTap,
|
||||
this.onPlayPause,
|
||||
this.onSkipNext,
|
||||
this.onPower,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Match mini player dimensions
|
||||
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
|
||||
const double artSize = cardHeight;
|
||||
const double borderRadius = Radii.xl; // 16px - same as mini player
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art or speaker icon - same size for consistent text alignment
|
||||
SizedBox(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
child: albumArtUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: albumArtUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 128,
|
||||
memCacheHeight: 128,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (_, __) => _buildSpeakerIcon(),
|
||||
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
|
||||
)
|
||||
: _buildSpeakerIcon(),
|
||||
),
|
||||
|
||||
// Player info
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Player name
|
||||
Row(
|
||||
children: [
|
||||
// Status indicator
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _getStatusColor(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
player.name,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamily: 'Roboto',
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Track name or status
|
||||
Text(
|
||||
_getSubtitle(context),
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
fontSize: 14,
|
||||
fontFamily: 'Roboto',
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Transport controls - compact sizing to align with mini player
|
||||
// Play/Pause and Next only shown when powered with content
|
||||
if (player.available && player.powered && trackInfo != null) ...[
|
||||
// Play/Pause - slight nudge right
|
||||
Transform.translate(
|
||||
offset: const Offset(3, 0),
|
||||
child: SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: textColor,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: onPlayPause,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip next - nudged right to close gap with power
|
||||
Transform.translate(
|
||||
offset: const Offset(6, 0),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.skip_next_rounded,
|
||||
color: textColor,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: onSkipNext,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Power button - smallest
|
||||
if (player.available)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.power_settings_new_rounded,
|
||||
color: player.powered ? textColor : textColor.withOpacity(0.5),
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onPower,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpeakerIcon() {
|
||||
// Darker shade of the card background to match album art square
|
||||
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||
return Container(
|
||||
color: iconBgColor,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.speaker_rounded,
|
||||
color: textColor.withOpacity(0.4),
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
if (!player.available) {
|
||||
return Colors.grey.withOpacity(0.5);
|
||||
}
|
||||
if (!player.powered) {
|
||||
return Colors.grey;
|
||||
}
|
||||
if (isPlaying) {
|
||||
return Colors.green;
|
||||
}
|
||||
if (trackInfo != null) {
|
||||
return Colors.orange; // Has content but paused
|
||||
}
|
||||
return Colors.grey.shade400; // Idle
|
||||
}
|
||||
|
||||
String _getSubtitle(BuildContext context) {
|
||||
if (!player.available) {
|
||||
return S.of(context)!.playerStateUnavailable;
|
||||
}
|
||||
if (!player.powered) {
|
||||
return S.of(context)!.playerStateOff;
|
||||
}
|
||||
if (trackInfo != null) {
|
||||
return trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||
}
|
||||
return S.of(context)!.playerStateIdle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
import '../../theme/palette_helper.dart';
|
||||
import '../../constants/timings.dart';
|
||||
import '../global_player_overlay.dart';
|
||||
import 'player_card.dart';
|
||||
|
||||
/// Overlay that reveals all available players with a staggered slide-up animation.
|
||||
/// Triggered by swiping down on the mini player or tapping the device button.
|
||||
class PlayerRevealOverlay extends StatefulWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final double miniPlayerBottom; // Bottom position of mini player
|
||||
final double miniPlayerHeight;
|
||||
|
||||
const PlayerRevealOverlay({
|
||||
super.key,
|
||||
required this.onDismiss,
|
||||
required this.miniPlayerBottom,
|
||||
required this.miniPlayerHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
|
||||
}
|
||||
|
||||
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _revealController;
|
||||
late Animation<double> _revealAnimation;
|
||||
late Animation<double> _backdropAnimation;
|
||||
|
||||
Timer? _refreshTimer;
|
||||
|
||||
// Track vertical drag for dismissal
|
||||
double _dragOffset = 0;
|
||||
bool _isDragging = false;
|
||||
|
||||
// Cache extracted colors per player for per-device accent colors
|
||||
final Map<String, ColorScheme?> _playerColors = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_revealController = AnimationController(
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// Quick smooth animation with subtle overshoot
|
||||
_revealAnimation = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
|
||||
_backdropAnimation = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// Preload all player track info and extract colors
|
||||
_preloadColorsForPlayers();
|
||||
|
||||
// Start reveal animation
|
||||
_revealController.duration = const Duration(milliseconds: 200);
|
||||
_revealController.forward();
|
||||
|
||||
// Auto-refresh player data
|
||||
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||
if (mounted) {
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
provider.refreshPlayers();
|
||||
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract accent colors from album art for each player
|
||||
Future<void> _preloadColorsForPlayers() async {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
await maProvider.preloadAllPlayerTracks();
|
||||
|
||||
final players = maProvider.availablePlayers;
|
||||
for (final player in players) {
|
||||
if (_playerColors.containsKey(player.playerId)) continue;
|
||||
|
||||
final track = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
if (track == null) continue;
|
||||
|
||||
final imageUrl = maProvider.getImageUrl(track, size: 128);
|
||||
if (imageUrl == null) continue;
|
||||
|
||||
try {
|
||||
final imageProvider = NetworkImage(imageUrl);
|
||||
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
|
||||
if (mounted && schemes != null) {
|
||||
final colorScheme = isDark ? schemes.$2 : schemes.$1;
|
||||
setState(() {
|
||||
_playerColors[player.playerId] = colorScheme;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore color extraction errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_revealController.dispose();
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Animate reveal (called from parent)
|
||||
void reveal() {
|
||||
_revealController.duration = const Duration(milliseconds: 200);
|
||||
_revealController.forward();
|
||||
}
|
||||
|
||||
/// Animate dismissal and call callback when done
|
||||
void dismiss() {
|
||||
// Trigger settle bounce on mini player when cards are ~70% collapsed
|
||||
// This creates the effect of cards "landing" on the mini player
|
||||
bool bounceFired = false;
|
||||
void checkBounce() {
|
||||
if (!bounceFired && _revealController.value < 0.3) {
|
||||
bounceFired = true;
|
||||
GlobalPlayerOverlay.triggerBounce();
|
||||
}
|
||||
}
|
||||
|
||||
_revealController.duration = const Duration(milliseconds: 150);
|
||||
_revealController.addListener(checkBounce);
|
||||
_revealController.reverse().then((_) {
|
||||
_revealController.removeListener(checkBounce);
|
||||
widget.onDismiss();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
// Negate delta so swipe down = cards move down (toward dismissal)
|
||||
_dragOffset -= details.primaryDelta ?? 0;
|
||||
// Clamp: allow moving down (positive) freely, limit moving up to 20px
|
||||
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
|
||||
// Dismiss if dragged down enough or with enough velocity
|
||||
if (_dragOffset > 100 || velocity > 500) {
|
||||
HapticFeedback.lightImpact();
|
||||
// Reset drag offset before dismiss so animation starts from correct position
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_dragOffset = 0;
|
||||
});
|
||||
dismiss();
|
||||
} else {
|
||||
// Spring back
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
_dragOffset = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Back gesture is handled at GlobalPlayerOverlay level
|
||||
return Consumer<MusicAssistantProvider>(
|
||||
builder: (context, maProvider, child) {
|
||||
final allPlayers = maProvider.availablePlayers;
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
final currentTrack = maProvider.currentTrack;
|
||||
|
||||
// Filter out the selected player - it's already shown in the mini player
|
||||
final players = allPlayers
|
||||
.where((p) => p.playerId != selectedPlayer?.playerId)
|
||||
.toList();
|
||||
|
||||
// Default card colors - used when player doesn't have extracted colors
|
||||
final defaultBgColor = colorScheme.primaryContainer;
|
||||
final defaultTextColor = colorScheme.onPrimaryContainer;
|
||||
|
||||
// Calculate total stack height for animation
|
||||
const cardHeight = 64.0;
|
||||
const cardSpacing = 12.0;
|
||||
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _revealAnimation,
|
||||
builder: (context, child) {
|
||||
final t = _revealAnimation.value;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Backdrop - tap to dismiss
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: dismiss,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.6 * _backdropAnimation.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Player cards - positioned above mini player
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
||||
child: GestureDetector(
|
||||
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Build player cards - all slide from behind mini player
|
||||
...List.generate(players.length, (index) {
|
||||
final player = players[index];
|
||||
final isPlaying = player.state == 'playing';
|
||||
|
||||
// Get track info for this player
|
||||
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||
|
||||
// Get album art URL - use track directly like mini player does
|
||||
String? albumArtUrl;
|
||||
if (playerTrack != null && player.available && player.powered) {
|
||||
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||
}
|
||||
|
||||
// Use per-player colors if available, otherwise use defaults
|
||||
final playerColorScheme = _playerColors[player.playerId];
|
||||
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||
|
||||
// Animation: all cards start hidden behind mini player
|
||||
// and fan out to their final positions with spring physics
|
||||
const baseOffset = 80.0;
|
||||
final reverseIndex = players.length - 1 - index;
|
||||
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||
|
||||
// Use the elastic animation value directly (already has bounce)
|
||||
final slideOffset = distanceToTravel * (1.0 - t);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(0, slideOffset),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: PlayerCard(
|
||||
player: player,
|
||||
trackInfo: playerTrack,
|
||||
albumArtUrl: albumArtUrl,
|
||||
isSelected: false,
|
||||
isPlaying: isPlaying,
|
||||
backgroundColor: cardBgColor,
|
||||
textColor: cardTextColor,
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
maProvider.selectPlayer(player);
|
||||
dismiss();
|
||||
},
|
||||
onPlayPause: () {
|
||||
if (isPlaying) {
|
||||
maProvider.pausePlayer(player.playerId);
|
||||
} else {
|
||||
maProvider.resumePlayer(player.playerId);
|
||||
}
|
||||
},
|
||||
onSkipNext: () {
|
||||
maProvider.nextTrack(player.playerId);
|
||||
},
|
||||
onPower: () {
|
||||
maProvider.togglePower(player.playerId);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,27 +64,8 @@ class PlayerSelector extends StatelessWidget {
|
||||
MusicAssistantProvider provider,
|
||||
List players,
|
||||
) {
|
||||
// Collapse player if expanded before showing sheet
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
}
|
||||
|
||||
// Slide mini player down out of the way
|
||||
GlobalPlayerOverlay.hidePlayer();
|
||||
|
||||
// Show the bottom sheet
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
builder: (context) {
|
||||
return _PlayerSelectorSheet();
|
||||
},
|
||||
).whenComplete(() {
|
||||
// Slide mini player back up when sheet is dismissed
|
||||
GlobalPlayerOverlay.showPlayer();
|
||||
});
|
||||
// Use the new player reveal animation
|
||||
GlobalPlayerOverlay.showPlayerReveal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +164,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 +286,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 +311,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
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.2.0-beta+19
|
||||
version: 2.3.3-beta+25
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user