Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c96880d7 | ||
|
|
540b95f558 | ||
|
|
a1c84f29c1 | ||
|
|
2cf3135f18 | ||
|
|
1b33c57cb7 | ||
|
|
3708b24d55 |
@@ -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
|
||||
|
||||
|
||||
+7
-1
@@ -195,6 +195,7 @@
|
||||
"language": "Sprache",
|
||||
"english": "Englisch",
|
||||
"german": "Deutsch",
|
||||
"spanish": "Spanisch",
|
||||
|
||||
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||
"sortAlphabetically": "Alphabetisch sortieren",
|
||||
@@ -266,5 +267,10 @@
|
||||
"startRadioOn": "{name} Radio starten auf...",
|
||||
"book": "Buch",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -970,6 +970,11 @@
|
||||
"description": "German language"
|
||||
},
|
||||
|
||||
"spanish": "Spanish",
|
||||
"@spanish": {
|
||||
"description": "Spanish language"
|
||||
},
|
||||
|
||||
"noTracksInPlaylist": "No tracks in playlist",
|
||||
"sortAlphabetically": "Sort alphabetically",
|
||||
"sortByYear": "Sort by year",
|
||||
@@ -1131,5 +1136,30 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -529,24 +529,35 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server version is >= 2.7.0b20 (uses Sendspin instead of builtin_player)
|
||||
bool _serverUsesSendspin() {
|
||||
/// 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 false;
|
||||
if (serverInfo == null) return null;
|
||||
|
||||
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"
|
||||
// Format: MAJOR.MINOR.PATCH[bBETA]
|
||||
final versionRegex = RegExp(r'^(\d+)\.(\d+)\.(\d+)(?:b(\d+))?');
|
||||
final match = versionRegex.firstMatch(versionStr);
|
||||
if (match == null) return false;
|
||||
if (match == null) return null;
|
||||
|
||||
final major = int.parse(match.group(1)!);
|
||||
final minor = int.parse(match.group(2)!);
|
||||
final patch = int.parse(match.group(3)!);
|
||||
final beta = match.group(4) != null ? int.parse(match.group(4)!) : null;
|
||||
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;
|
||||
@@ -562,6 +573,32 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
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;
|
||||
|
||||
@@ -665,19 +702,24 @@ 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 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();
|
||||
if (authToken != null) {
|
||||
_sendspinService!.setAuthToken(authToken);
|
||||
@@ -706,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) {
|
||||
@@ -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
|
||||
void _handleSendspinPlay(String streamUrl, Map<String, dynamic> trackInfo) async {
|
||||
_logger.log('🎵 Sendspin: Play command received');
|
||||
@@ -2972,4 +3054,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';
|
||||
|
||||
@@ -411,73 +411,86 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Language picker
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
S.of(context)!.language,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Consumer<LocaleProvider>(
|
||||
builder: (context, localeProvider, _) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: SegmentedButton<String?>(
|
||||
segments: [
|
||||
ButtonSegment<String?>(
|
||||
value: null,
|
||||
label: Text(S.of(context)!.system),
|
||||
icon: const Icon(Icons.auto_mode_rounded),
|
||||
),
|
||||
const ButtonSegment<String?>(
|
||||
value: 'en',
|
||||
label: Text('English'),
|
||||
icon: Icon(Icons.language_rounded),
|
||||
),
|
||||
const ButtonSegment<String?>(
|
||||
value: 'de',
|
||||
label: Text('Deutsch'),
|
||||
icon: Icon(Icons.language_rounded),
|
||||
),
|
||||
],
|
||||
selected: {localeProvider.locale?.languageCode},
|
||||
onSelectionChanged: (Set<String?> newSelection) {
|
||||
final code = newSelection.first;
|
||||
localeProvider.setLocale(code != null ? Locale(code) : null);
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.primaryContainer;
|
||||
}
|
||||
return colorScheme.surfaceVariant.withOpacity(0.5);
|
||||
}),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return colorScheme.onPrimaryContainer;
|
||||
}
|
||||
return colorScheme.onSurfaceVariant;
|
||||
}),
|
||||
side: WidgetStateProperty.all(BorderSide.none),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
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),
|
||||
@@ -496,7 +509,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,12 +120,11 @@ class SendspinService {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../constants/timings.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
|
||||
|
||||
@@ -182,7 +183,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
child: currentPlayers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No players available',
|
||||
S.of(context)!.noPlayersAvailable,
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||
),
|
||||
)
|
||||
@@ -304,12 +305,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
!player.available
|
||||
? 'Unavailable'
|
||||
? S.of(context)!.playerStateUnavailable
|
||||
: !isOn
|
||||
? 'Off'
|
||||
? S.of(context)!.playerStateOff
|
||||
: hasContent && playerTrack != null
|
||||
? playerTrack.name
|
||||
: 'Idle',
|
||||
: S.of(context)!.playerStateIdle,
|
||||
style: TextStyle(
|
||||
color: player.available
|
||||
? colorScheme.onSurfaceVariant
|
||||
@@ -329,7 +330,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Selected',
|
||||
S.of(context)!.playerSelected,
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 10,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.3.1-beta+23
|
||||
version: 2.3.3-beta+25
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user