Compare commits

...
Author SHA1 Message Date
Your NameandClaude Opus 4.5 d5c96880d7 chore: Bump version to 2.3.3-beta+25
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 10:10:43 +00:00
Your NameandClaude Opus 4.5 540b95f558 Add MA version detection for Sendspin proxy support
- Add _parseServerVersion() helper for version comparisons
- Add _serverHasSendspinProxy() to detect MA 2.8.0+ (has /sendspin proxy)
- Add _logSendspinTroubleshooting() with version-specific guidance
- Update connection logic to try local proxy fallback for local IPs
- Improve logging to show server version and proxy availability
- MA 2.7.x users now get clear guidance: upgrade to 2.8.0+ or configure proxy

Fixes #17

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 21:50:54 +00:00
Your Name a1c84f29c1 fix: Fix followRedirects usage and add missing painting import 2025-12-21 21:00:02 +00:00
Your Name 2cf3135f18 fix(ci): Add flutter gen-l10n step to build workflow 2025-12-21 20:46:21 +00:00
Your NameandClaude Opus 4.5 1b33c57cb7 fix: Improve local IP and Sendspin connection handling
Fixes issue #17 - users unable to connect via local IP or see local player

Changes:
1. **Local IP connection handling**:
   - Don't follow HTTP->HTTPS redirects for local IPs
   - Detect 308/301/302 redirects and handle gracefully
   - Prevents certificate verification errors when server redirects to HTTPS

2. **Sendspin connection logic**:
   - Remove non-existent API calls (sendspin/connection_info, webrtc_offer, etc.)
   - For local IPs: Connect directly to ws://ip:8927/sendspin
   - For external/HTTPS: Use proxy at wss://server/sendspin with auth
   - Better error messages explaining what to configure

3. **Utility improvements**:
   - Add _isLocalNetworkHost() helper for IP detection
   - Include .ts.net (Tailscale) as local network

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:35:52 +00:00
Your NameandClaude Opus 4.5 3708b24d55 feat(i18n): add Spanish language support with improved picker UI
- Add complete Spanish translations (app_es.arb)
- Replace cramped SegmentedButton with ListTile + Dialog picker
- Localize player states (Off, Idle, Unavailable, Selected)
- Localize Material You description
- Scales better for multiple languages
- Bump version to 2.3.2-beta+24

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 09:27:23 +00:00
12 changed files with 852 additions and 220 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
- name: Generate code (Drift database) - name: Generate code (Drift database)
run: dart run build_runner build --delete-conflicting-outputs run: dart run build_runner build --delete-conflicting-outputs
- name: Generate localizations
run: flutter gen-l10n
- name: Generate launcher icons - name: Generate launcher icons
run: flutter pub run flutter_launcher_icons run: flutter pub run flutter_launcher_icons
+7 -1
View File
@@ -195,6 +195,7 @@
"language": "Sprache", "language": "Sprache",
"english": "Englisch", "english": "Englisch",
"german": "Deutsch", "german": "Deutsch",
"spanish": "Spanisch",
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste", "noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
"sortAlphabetically": "Alphabetisch sortieren", "sortAlphabetically": "Alphabetisch sortieren",
@@ -266,5 +267,10 @@
"startRadioOn": "{name} Radio starten auf...", "startRadioOn": "{name} Radio starten auf...",
"book": "Buch", "book": "Buch",
"audiobookSingular": "Hörbuch", "audiobookSingular": "Hörbuch",
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}" "trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
"playerStateUnavailable": "Nicht verfügbar",
"playerStateOff": "Aus",
"playerStateIdle": "Inaktiv",
"playerSelected": "Ausgewählt"
} }
+30
View File
@@ -970,6 +970,11 @@
"description": "German language" "description": "German language"
}, },
"spanish": "Spanish",
"@spanish": {
"description": "Spanish language"
},
"noTracksInPlaylist": "No tracks in playlist", "noTracksInPlaylist": "No tracks in playlist",
"sortAlphabetically": "Sort alphabetically", "sortAlphabetically": "Sort alphabetically",
"sortByYear": "Sort by year", "sortByYear": "Sort by year",
@@ -1131,5 +1136,30 @@
"type": "int" "type": "int"
} }
} }
},
"materialYouDescription": "Use system colors (Android 12+)",
"@materialYouDescription": {
"description": "Description for Material You theme option"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
},
"playerStateOff": "Off",
"@playerStateOff": {
"description": "Player state when powered off"
},
"playerStateIdle": "Idle",
"@playerStateIdle": {
"description": "Player state when idle"
},
"playerSelected": "Selected",
"@playerSelected": {
"description": "Label for selected player"
} }
} }
+472
View File
@@ -0,0 +1,472 @@
{
"@@locale": "es",
"appTitle": "Ensemble",
"connecting": "Conectando...",
"connected": "Conectado",
"disconnected": "Desconectado",
"serverAddress": "Dirección del servidor",
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
"yourName": "Tu nombre",
"yourFirstName": "Tu nombre de pila",
"portOptional": "Puerto (Opcional)",
"portHint": "ej., 8095 o dejar en blanco",
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
"username": "Usuario",
"password": "Contraseña",
"detectAndConnect": "Detectar y conectar",
"connect": "Conectar",
"disconnect": "Desconectar",
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
"detectedAuthType": "Detectado: {authType}",
"@detectedAuthType": {
"placeholders": {
"authType": {
"type": "String"
}
}
},
"serverRequiresAuth": "Este servidor requiere autenticación",
"tailscaleVpnConnection": "Conexión VPN Tailscale",
"unencryptedConnection": "Conexión sin cifrar",
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
"pleaseEnterName": "Por favor, introduce tu nombre",
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
"detectingAuth": "Detectando autenticación...",
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
"noAuthentication": "Sin autenticación",
"httpBasicAuth": "Autenticación HTTP Basic",
"authelia": "Authelia",
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
"recentlyPlayed": "Reproducido recientemente",
"discoverArtists": "Descubrir artistas",
"discoverAlbums": "Descubrir álbumes",
"continueListening": "Continuar escuchando",
"discoverAudiobooks": "Descubrir audiolibros",
"discoverSeries": "Descubrir series",
"favoriteAlbums": "Álbumes favoritos",
"favoriteArtists": "Artistas favoritos",
"favoriteTracks": "Canciones favoritas",
"searchMusic": "Buscar música...",
"searchForContent": "Buscar artistas, álbumes o canciones",
"retry": "Reintentar",
"all": "Todo",
"artists": "Artistas",
"albums": "Álbumes",
"tracks": "Canciones",
"playlists": "Listas de reproducción",
"audiobooks": "Audiolibros",
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
"queue": "Cola",
"playerQueue": "Cola de {playerName}",
"@playerQueue": {
"placeholders": {
"playerName": {
"type": "String"
}
}
},
"noPlayerSelected": "Ningún reproductor seleccionado",
"undo": "Deshacer",
"removedItem": "Eliminado {itemName}",
"@removedItem": {
"placeholders": {
"itemName": {
"type": "String"
}
}
},
"settings": "Ajustes",
"debugLogs": "Registros de depuración",
"themeMode": "Modo de tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
"viewDebugLogs": "Ver registros de depuración",
"viewAllPlayers": "Ver todos los reproductores",
"copyLogs": "Copiar registros",
"clearLogs": "Borrar registros",
"copyList": "Copiar lista",
"close": "Cerrar",
"allPlayersCount": "Todos los reproductores ({count})",
"@allPlayersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
"@errorLoadingPlayers": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"logsCopied": "Registros copiados al portapapeles",
"logsCleared": "Registros borrados",
"playerListCopied": "Lista de reproductores copiada al portapapeles",
"noLogsYet": "Aún no hay registros",
"infoPlus": "Info+",
"warnings": "Advertencias",
"errors": "Errores",
"showingEntries": "Mostrando {filtered} de {total} entradas",
"@showingEntries": {
"placeholders": {
"filtered": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"thisDevice": "Este dispositivo",
"ghostPlayer": "Reproductor fantasma (duplicado)",
"unavailableCorrupt": "No disponible/Corrupto",
"playerId": "ID: {id}",
"@playerId": {
"placeholders": {
"id": {
"type": "String"
}
}
},
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
"@playerInfo": {
"placeholders": {
"available": {
"type": "String"
},
"provider": {
"type": "String"
}
}
},
"shareBugReport": "Compartir informe de errores",
"moreOptions": "Más opciones",
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
"@failedToUpdateFavorite": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noPlayersAvailable": "No hay reproductores disponibles",
"albumAddedToQueue": "Álbum añadido a la cola",
"tracksAddedToQueue": "Canciones añadidas a la cola",
"play": "Reproducir",
"noPlayersFound": "No se encontraron reproductores",
"startingRadio": "Iniciando radio de {name}",
"@startingRadio": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToStartRadio": "Error al iniciar radio: {error}",
"@failedToStartRadio": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
"@startingRadioOnPlayer": {
"placeholders": {
"name": {
"type": "String"
},
"playerName": {
"type": "String"
}
}
},
"addedRadioToQueue": "Radio de {name} añadida a la cola",
"@addedRadioToQueue": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToAddToQueue": "Error al añadir a la cola: {error}",
"@failedToAddToQueue": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"radio": "Radio",
"inLibrary": "En la biblioteca",
"noAlbumsFound": "No se encontraron álbumes",
"playing": "Reproduciendo {name}",
"@playing": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"playingTrack": "Reproduciendo canción",
"markedAsFinished": "{name} marcado como terminado",
"@markedAsFinished": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkFinished": "Error al marcar como terminado: {error}",
"@failedToMarkFinished": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markedAsUnplayed": "{name} marcado como no reproducido",
"@markedAsUnplayed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
"@failedToMarkUnplayed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToPlay": "Error al reproducir: {error}",
"@failedToPlay": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"markAsFinished": "Marcar como terminado",
"markAsUnplayed": "Marcar como no reproducido",
"byAuthor": "Por {author}",
"@byAuthor": {
"placeholders": {
"author": {
"type": "String"
}
}
},
"audiobookCount": "{count} audiolibro(s)",
"@audiobookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"loading": "Cargando...",
"bookCount": "{count} libro(s)",
"@bookCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"books": "Libros",
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
"noAudiobooks": "No hay audiolibros",
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
"noFavoriteArtists": "No hay artistas favoritos",
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
"noFavoriteAlbums": "No hay álbumes favoritos",
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
"noFavoriteTracks": "No hay canciones favoritas",
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
"loadSeries": "Cargar series",
"notConnected": "No conectado a Music Assistant",
"notConnectedTitle": "No conectado",
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
"configureServer": "Configurar servidor",
"noArtistsFound": "No se encontraron artistas",
"noTracksFound": "No se encontraron canciones",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"queueIsEmpty": "La cola está vacía",
"noResultsFound": "No se encontraron resultados",
"refresh": "Actualizar",
"debugConsole": "Consola de depuración",
"copy": "Copiar",
"clear": "Borrar",
"noLogsToCopy": "No hay registros para copiar",
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
"showDebug": "Mostrar depuración",
"hideDebug": "Ocultar depuración",
"chapters": "Capítulos",
"noChapters": "Sin capítulos",
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
"errorSeeking": "Error al buscar: {error}",
"@errorSeeking": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"error": "Error: {error}",
"@error": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"home": "Inicio",
"library": "Biblioteca",
"homeScreen": "Pantalla de inicio",
"metadataApis": "APIs de metadatos",
"theme": "Tema",
"materialYou": "Material You",
"adaptiveTheme": "Tema adaptativo",
"language": "Idioma",
"english": "Inglés",
"german": "Alemán",
"spanish": "Español",
"noTracksInPlaylist": "No hay canciones en la lista",
"sortAlphabetically": "Ordenar alfabéticamente",
"sortByYear": "Ordenar por año",
"sortBySeriesOrder": "Ordenar por orden de serie",
"listView": "Vista de lista",
"gridView": "Vista de cuadrícula",
"noBooksInSeries": "No se encontraron libros en esta serie",
"artist": "Artista",
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
"addedToFavorites": "Añadido a favoritos",
"removedFromFavorites": "Eliminado de favoritos",
"unknown": "Desconocido",
"noUpcomingTracks": "No hay canciones próximas",
"showAll": "Mostrar todo",
"showFavoritesOnly": "Mostrar solo favoritos",
"changeView": "Cambiar vista",
"authors": "Autores",
"series": "Series",
"shows": "Programas",
"podcasts": "Podcasts",
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
"playlist": "Lista de reproducción",
"connectionError": "Error de conexión",
"twoColumnGrid": "Cuadrícula de 2 columnas",
"threeColumnGrid": "Cuadrícula de 3 columnas",
"fromProviders": "De proveedores",
"resume": "Continuar",
"about": "Acerca de",
"inProgress": "En progreso",
"narratedBy": "Narrado por {narrators}",
"@narratedBy": {
"placeholders": {
"narrators": {
"type": "String"
}
}
},
"unknownNarrator": "Narrador desconocido",
"unknownAuthor": "Autor desconocido",
"loadingChapters": "Cargando capítulos...",
"noChapterInfoAvailable": "No hay información de capítulos disponible",
"percentComplete": "{percent}% completado",
"@percentComplete": {
"placeholders": {
"percent": {
"type": "int"
}
}
},
"theAudioDbApiKey": "Clave API de TheAudioDB",
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
"audiobookLibraries": "Bibliotecas de audiolibros",
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
"unknownLibrary": "Biblioteca desconocida",
"noSeriesFound": "No se encontraron series",
"ensembleBugReport": "Informe de errores de Ensemble",
"byOwner": "Por {owner}",
"@byOwner": {
"placeholders": {
"owner": {
"type": "String"
}
}
},
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
"search": "Buscar",
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
"lastFmApiKey": "Clave API de Last.fm",
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
"swipeToSwitchDevice": "Desliza para cambiar de dispositivo",
"chapterNumber": "Capítulo {number}",
"@chapterNumber": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"pcmAudio": "Audio PCM",
"playOn": "Reproducir en...",
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
"addToQueueOn": "Añadir a la cola en...",
"startRadioOn": "Iniciar radio de {name} en...",
"@startRadioOn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"book": "libro",
"audiobookSingular": "audiolibro",
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
"@trackCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"materialYouDescription": "Usar colores del sistema (Android 12+)",
"playerStateUnavailable": "No disponible",
"playerStateOff": "Apagado",
"playerStateIdle": "Inactivo",
"playerSelected": "Seleccionado"
}
+144 -41
View File
@@ -529,24 +529,35 @@ class MusicAssistantProvider with ChangeNotifier {
} }
} }
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player) /// Parse server version into components for version comparisons
bool _serverUsesSendspin() { /// Returns null if version cannot be parsed
({int major, int minor, int patch, int? beta})? _parseServerVersion() {
final serverInfo = _api?.serverInfo; final serverInfo = _api?.serverInfo;
if (serverInfo == null) return false; if (serverInfo == null) return null;
final versionStr = serverInfo['server_version'] as String?; final versionStr = serverInfo['server_version'] as String?;
if (versionStr == null) return false; if (versionStr == null) return null;
// Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1" // Parse version like "2.8.0b2" or "2.7.0b20" or "2.7.1"
// Format: MAJOR.MINOR.PATCH[bBETA] // Format: MAJOR.MINOR.PATCH[bBETA]
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?'); final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
final match = versionRegex.firstMatch(versionStr); final match = versionRegex.firstMatch(versionStr);
if (match == null) return false; if (match == null) return null;
final major = int.parse(match.group(1)!); return (
final minor = int.parse(match.group(2)!); major: int.parse(match.group(1)!),
final patch = int.parse(match.group(3)!); minor: int.parse(match.group(2)!),
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null; 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 // Compare with 2.7.0b20
if (major > 2) return true; if (major > 2) return true;
@@ -562,6 +573,32 @@ class MusicAssistantProvider with ChangeNotifier {
return beta >= 20; 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 { Future<void> _registerLocalPlayer() async {
if (_api == null) return; if (_api == null) return;
@@ -665,19 +702,24 @@ class MusicAssistantProvider with ChangeNotifier {
/// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+) /// Connect to Music Assistant via Sendspin protocol (MA 2.7.0b20+)
/// This is the replacement for builtin_player when that API is not available. /// This is the replacement for builtin_player when that API is not available.
/// ///
/// Connection strategy: /// Connection strategy depends on MA version and network:
/// 1. If server is HTTPS, try external wss://{server}/sendspin first /// - MA 2.8.0+: Has built-in /sendspin proxy, works with any reverse proxy setup
/// 2. Get local_ws_url from API and try that /// - MA 2.7.x: NO proxy, must either:
/// 3. WebRTC fallback as last resort (requires TURN servers) /// - Use local IP with port 8927 exposed, OR
/// - Manually configure reverse proxy to route /sendspin to port 8927
Future<bool> _connectViaSendspin() async { Future<bool> _connectViaSendspin() async {
if (_api == null || _serverUrl == null) return false; if (_api == null || _serverUrl == null) return false;
try { try {
final serverVersion = _getServerVersionString();
final hasProxy = _serverHasSendspinProxy();
_logger.log('Sendspin: Server version $serverVersion, has proxy: $hasProxy');
// Initialize Sendspin service // Initialize Sendspin service
_sendspinService?.dispose(); _sendspinService?.dispose();
_sendspinService = SendspinService(_serverUrl!); _sendspinService = SendspinService(_serverUrl!);
// Set auth token for MA 2.7.1+ proxy authentication // Set auth token for proxy authentication (MA 2.8.0+ or manually configured proxy)
final authToken = await SettingsService.getMaAuthToken(); final authToken = await SettingsService.getMaAuthToken();
if (authToken != null) { if (authToken != null) {
_sendspinService!.setAuthToken(authToken); _sendspinService!.setAuthToken(authToken);
@@ -706,42 +748,59 @@ class MusicAssistantProvider with ChangeNotifier {
_sendspinService!.onStreamEnd = _handleSendspinStreamEnd; _sendspinService!.onStreamEnd = _handleSendspinStreamEnd;
final playerId = await DeviceIdService.getOrCreateDevicePlayerId(); final playerId = await DeviceIdService.getOrCreateDevicePlayerId();
_logger.log('Sendspin: Player ID: $playerId');
// Strategy 1: If server is HTTPS, try external wss:// first // Parse server URL to determine connection strategy
final isHttps = _serverUrl!.startsWith('https://') || final serverUri = Uri.parse(_serverUrl!.startsWith('http')
(!_serverUrl!.contains('://') && !_serverUrl!.contains(':')); ? _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(); final connected = await _sendspinService!.connect();
if (connected) { if (connected) {
_sendspinConnected = true; _sendspinConnected = true;
_logger.log('✅ Sendspin: Connected via external URL'); _logger.log('✅ Sendspin: Connected via external proxy');
return true; 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 // All strategies failed - provide version-specific guidance
final connectionInfo = await _api!.getSendspinConnectionInfo(playerId); _logger.log('❌ Sendspin: All connection strategies failed');
if (connectionInfo != null) { _logSendspinTroubleshooting(isLocalIp, hasProxy, serverVersion);
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');
return false; return false;
} catch (e) { } catch (e) {
@@ -750,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 /// Handle Sendspin play command
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async { void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
_logger.log('🎵 Sendspin: Play command received'); _logger.log('🎵 Sendspin: Play command received');
@@ -2972,4 +3054,25 @@ class MusicAssistantProvider with ChangeNotifier {
_api?.dispose(); _api?.dispose();
super.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
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../models/media_item.dart'; import '../models/media_item.dart';
import '../models/player.dart'; import '../models/player.dart';
import '../services/music_assistant_api.dart'; import '../services/music_assistant_api.dart';
+79 -66
View File
@@ -411,73 +411,86 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Language picker // Language picker
Container( Consumer<LocaleProvider>(
padding: const EdgeInsets.all(16), builder: (context, localeProvider, _) {
decoration: BoxDecoration( String getLanguageName(String? code) {
color: colorScheme.surfaceVariant.withOpacity(0.5), switch (code) {
borderRadius: BorderRadius.circular(8), case 'en':
), return 'English';
child: Column( case 'de':
crossAxisAlignment: CrossAxisAlignment.start, return 'Deutsch';
children: [ case 'es':
Text( return 'Español';
S.of(context)!.language, default:
style: textTheme.bodySmall?.copyWith( return S.of(context)!.system;
color: colorScheme.onSurfaceVariant, }
), }
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),
), ),
const SizedBox(height: 12), trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Consumer<LocaleProvider>( onTap: () {
builder: (context, localeProvider, _) { showDialog(
return SizedBox( context: context,
width: double.infinity, builder: (dialogContext) {
child: SegmentedButton<String?>( return AlertDialog(
segments: [ title: Text(S.of(context)!.language),
ButtonSegment<String?>( content: Column(
value: null, mainAxisSize: MainAxisSize.min,
label: Text(S.of(context)!.system), children: [
icon: const Icon(Icons.auto_mode_rounded), RadioListTile<String?>(
), title: Text(S.of(context)!.system),
const ButtonSegment<String?>( subtitle: const Text('Auto'),
value: 'en', value: null,
label: Text('English'), groupValue: localeProvider.locale?.languageCode,
icon: Icon(Icons.language_rounded), onChanged: (value) {
), localeProvider.setLocale(null);
const ButtonSegment<String?>( Navigator.pop(dialogContext);
value: 'de', },
label: Text('Deutsch'), ),
icon: Icon(Icons.language_rounded), RadioListTile<String?>(
), title: const Text('English'),
], value: 'en',
selected: {localeProvider.locale?.languageCode}, groupValue: localeProvider.locale?.languageCode,
onSelectionChanged: (Set<String?> newSelection) { onChanged: (value) {
final code = newSelection.first; localeProvider.setLocale(const Locale('en'));
localeProvider.setLocale(code != null ? Locale(code) : null); Navigator.pop(dialogContext);
}, },
style: ButtonStyle( ),
backgroundColor: WidgetStateProperty.resolveWith((states) { RadioListTile<String?>(
if (states.contains(WidgetState.selected)) { title: const Text('Deutsch'),
return colorScheme.primaryContainer; value: 'de',
} groupValue: localeProvider.locale?.languageCode,
return colorScheme.surfaceVariant.withOpacity(0.5); onChanged: (value) {
}), localeProvider.setLocale(const Locale('de'));
foregroundColor: WidgetStateProperty.resolveWith((states) { Navigator.pop(dialogContext);
if (states.contains(WidgetState.selected)) { },
return colorScheme.onPrimaryContainer; ),
} RadioListTile<String?>(
return colorScheme.onSurfaceVariant; title: const Text('Español'),
}), value: 'es',
side: WidgetStateProperty.all(BorderSide.none), groupValue: localeProvider.locale?.languageCode,
shape: WidgetStateProperty.all( onChanged: (value) {
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), localeProvider.setLocale(const Locale('es'));
), Navigator.pop(dialogContext);
},
),
],
), ),
), );
); },
}, );
), },
], );
), },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -496,7 +509,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: TextStyle(color: colorScheme.onSurface), style: TextStyle(color: colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Use system colors (Android 12+)', S.of(context)!.materialYouDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
), ),
value: themeProvider.useMaterialTheme, value: themeProvider.useMaterialTheme,
+97 -19
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'auth_strategy.dart'; import 'auth_strategy.dart';
import 'no_auth_strategy.dart'; import 'no_auth_strategy.dart';
@@ -86,6 +87,22 @@ class AuthManager {
return baseUrl; 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 /// Try to detect auth strategy for a given URL
/// Returns the strategy if successful, null if connection failed /// Returns the strategy if successful, null if connection failed
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async { Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
@@ -187,15 +204,50 @@ class AuthManager {
_logger.log('Checking MA API at: $apiUrl'); _logger.log('Checking MA API at: $apiUrl');
// Try to get server info - this tells us if auth is required // Use IOClient to control redirect behavior for local IPs
// Note: The command is just 'info', not 'server/info' final isLocalIp = _isLocalIpAddress(uri.host);
final response = await http.post( http.Response response;
apiUrl,
headers: {'Content-Type': 'application/json'}, if (isLocalIp) {
body: jsonEncode({ // For local IPs, don't follow redirects - handle them explicitly
'command': 'info', final client = HttpClient();
}), try {
).timeout(const Duration(seconds: 5)); 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 response: ${response.statusCode}');
_logger.log('MA API body: ${response.body}'); _logger.log('MA API body: ${response.body}');
@@ -255,21 +307,47 @@ class AuthManager {
/// Test if server is accessible without authentication /// Test if server is accessible without authentication
Future<bool> _canConnectWithoutAuth(String serverUrl) async { Future<bool> _canConnectWithoutAuth(String serverUrl) async {
try { try {
// Test root endpoint - Music Assistant doesn't have /api/info final uri = Uri.parse(serverUrl);
final testUrl = serverUrl; _logger.log('No-auth test URL: $serverUrl');
_logger.log('No-auth test URL: $testUrl');
final response = await http.get( final isLocalIp = _isLocalIpAddress(uri.host);
Uri.parse(testUrl), int statusCode;
// Don't follow redirects - we want to see if server requires auth
).timeout(const Duration(seconds: 10));
_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 // 200 = no auth required
// 302/307 = redirect (probably to auth) // 302/307/308 = redirect (probably to auth or HTTPS)
// 401 = auth required // 401 = auth required
if (response.statusCode == 200) { if (statusCode == 200) {
return true; return true;
} }
+9 -83
View File
@@ -2302,89 +2302,15 @@ class MusicAssistantAPI {
return _eventStreams['sendspin_player']!.stream; return _eventStreams['sendspin_player']!.stream;
} }
/// Get Sendspin connection info for a player // NOTE: The following Sendspin API methods were removed because they don't exist in MA:
/// Returns connection details including: // - getSendspinConnectionInfo (sendspin/connection_info)
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin) // - sendspinOffer (sendspin/webrtc_offer)
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback // - sendspinAnswer (sendspin/webrtc_answer)
/// - player_id: The player's ID // - sendspinIceCandidate (sendspin/ice_candidate)
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async { //
try { // Sendspin connection is handled directly via WebSocket to:
_logger.log('Sendspin: Getting connection info for player $playerId'); // - Local: ws://{server-ip}:8927/sendspin
// - External: wss://{server}/sendspin (via MA's proxy with auth)
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
}
}
/// Update Sendspin player state /// Update Sendspin player state
/// Similar to updateBuiltinPlayerState but for Sendspin protocol /// Similar to updateBuiltinPlayerState but for Sendspin protocol
+3 -4
View File
@@ -120,12 +120,11 @@ class SendspinService {
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true); connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
} }
// Strategy 2: If external failed or server is HTTP, try local connection // Note: Local network connections (ws://ip:8927/sendspin) are handled
// Note: We would need local_ws_url from getSendspinConnectionInfo API // by the provider calling connectWithUrl() directly, not by this method.
// For now, skip this step and fall back to WebRTC (handled by provider)
if (!connected) { if (!connected) {
_logger.log('Sendspin: External connection failed, WebRTC fallback needed'); _logger.log('Sendspin: External proxy connection failed');
_updateState(SendspinConnectionState.error); _updateState(SendspinConnectionState.error);
return false; return false;
} }
+6 -5
View File
@@ -4,6 +4,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import '../constants/timings.dart'; import '../constants/timings.dart';
import '../l10n/app_localizations.dart';
import '../providers/music_assistant_provider.dart'; import '../providers/music_assistant_provider.dart';
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
@@ -182,7 +183,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
child: currentPlayers.isEmpty child: currentPlayers.isEmpty
? Center( ? Center(
child: Text( child: Text(
'No players available', S.of(context)!.noPlayersAvailable,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)), style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
), ),
) )
@@ -304,12 +305,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
Expanded( Expanded(
child: Text( child: Text(
!player.available !player.available
? 'Unavailable' ? S.of(context)!.playerStateUnavailable
: !isOn : !isOn
? 'Off' ? S.of(context)!.playerStateOff
: hasContent && playerTrack != null : hasContent && playerTrack != null
? playerTrack.name ? playerTrack.name
: 'Idle', : S.of(context)!.playerStateIdle,
style: TextStyle( style: TextStyle(
color: player.available color: player.available
? colorScheme.onSurfaceVariant ? colorScheme.onSurfaceVariant
@@ -329,7 +330,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
'Selected', S.of(context)!.playerSelected,
style: TextStyle( style: TextStyle(
color: colorScheme.primary, color: colorScheme.primary,
fontSize: 10, fontSize: 10,
+1 -1
View File
@@ -1,7 +1,7 @@
name: ensemble name: ensemble
description: A minimalistic Music Assistant client built with Flutter description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none' publish_to: 'none'
version: 2.3.1-beta+23 version: 2.3.3-beta+25
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'