Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -20,6 +22,7 @@ import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.StreamEntry
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import de.waypointaudio.viewmodel.WaypointViewModel
@@ -30,7 +33,7 @@ import de.waypointaudio.viewmodel.WaypointViewModel
* Öffnet sich über das sichtbare Begleitmusik-Panel oder über das Overflow-Menü;
* zeigt alle Optionen für die aktuell gewählte Tour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun BegleitmusikDialog(
tourName: String,
@@ -40,19 +43,33 @@ fun BegleitmusikDialog(
val context = LocalContext.current
val initialSettings by viewModel.musicSettings.collectAsState()
val musicPlaying by viewModel.musicPlaying.collectAsState()
val audioLibrary by viewModel.audioLibrary.collectAsState()
// Sub-Dialog: Auswahl aus der Audio-Bibliothek
var showLibraryPicker by remember { mutableStateOf(false) }
// Lokaler Bearbeitungszustand erst beim Speichern in VM schreiben
var enabled by remember(initialSettings) { mutableStateOf(initialSettings.enabled) }
var sourceType by remember(initialSettings) { mutableStateOf(initialSettings.sourceType) }
var localPlaylist by remember(initialSettings) { mutableStateOf(initialSettings.localPlaylist) }
var streamUrl by remember(initialSettings) { mutableStateOf(initialSettings.streamUrl ?: "") }
// Stream-Playlist Legacy streamUrl wird beim Initialisieren in einen Eintrag migriert,
// sodass im Dialog immer die neue Repräsentation verwendet wird.
var streamPlaylist by remember(initialSettings) {
mutableStateOf(initialSettings.effectiveStreamPlaylist)
}
var behavior by remember(initialSettings) { mutableStateOf(initialSettings.behavior) }
var fadeDurationMs by remember(initialSettings) { mutableStateOf(initialSettings.fadeDurationMs) }
var duckVolume by remember(initialSettings) { mutableStateOf(initialSettings.duckVolume) }
var shuffle by remember(initialSettings) { mutableStateOf(initialSettings.shuffle) }
var autoStartAfterWaypoint by remember(initialSettings) { mutableStateOf(initialSettings.autoStartAfterWaypoint) }
// Eingabezustand für „neuen Stream hinzufügen"
var newStreamName by remember(initialSettings) { mutableStateOf("") }
var newStreamUrl by remember(initialSettings) { mutableStateOf("") }
var newStreamError by remember { mutableStateOf("") }
var streamUrlError by remember { mutableStateOf("") }
// Index des gerade editierten Stream-Eintrags (-1 = kein Edit-Modus)
var editingStreamIndex by remember { mutableStateOf(-1) }
// Launcher für mehrere Audiodateien
val multiPickLauncher = rememberLauncherForActivityResult(
@@ -84,23 +101,19 @@ fun BegleitmusikDialog(
fun validateAndSave(): Boolean {
if (enabled && sourceType == MusicSourceType.STREAM_URL) {
val url = streamUrl.trim()
if (url.isBlank()) {
streamUrlError = context.getString(R.string.music_stream_url_empty_error)
return false
}
val lower = url.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
streamUrlError = context.getString(R.string.music_stream_url_invalid_error)
if (streamPlaylist.isEmpty()) {
streamUrlError = context.getString(R.string.music_stream_playlist_empty_error)
return false
}
}
streamUrlError = ""
// streamUrl-Legacy-Feld leer lassen Playlist ist jetzt kanonisch.
val settings = TourAudioSettings(
enabled = enabled,
sourceType = sourceType,
localPlaylist = localPlaylist,
streamUrl = streamUrl.trim().ifBlank { null },
streamPlaylist = streamPlaylist,
streamUrl = null,
behavior = behavior,
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
@@ -111,6 +124,18 @@ fun BegleitmusikDialog(
return true
}
fun validateStreamUrlInput(url: String): String? {
val trimmed = url.trim()
if (trimmed.isBlank()) {
return context.getString(R.string.music_stream_url_empty_error)
}
val lower = trimmed.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
return context.getString(R.string.music_stream_url_invalid_error)
}
return null
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
@@ -187,10 +212,16 @@ fun BegleitmusikDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (localPlaylist.isEmpty()) {
// Hinweis je nach Verfügbarkeit der Audio-Bibliothek anpassen,
// damit die User wissen, dass sie auch bereits hinzugefügte
// Dateien erneut auswählen können.
Text(
stringResource(R.string.music_playlist_empty),
if (audioLibrary.isEmpty())
stringResource(R.string.music_playlist_empty)
else
stringResource(R.string.music_playlist_empty_with_library),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
localPlaylist.forEachIndexed { idx, item ->
@@ -229,7 +260,11 @@ fun BegleitmusikDialog(
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
multiPickLauncher.launch(
@@ -241,6 +276,21 @@ fun BegleitmusikDialog(
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.music_playlist_add))
}
// Aus Bibliothek auswählen (bereits bekannte Audiodateien
// wiederverwenden tour-übergreifend & inkl. Wegpunkt-Audios)
if (audioLibrary.isNotEmpty()) {
OutlinedButton(
onClick = { showLibraryPicker = true }
) {
Icon(
Icons.Filled.LibraryMusic,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.music_playlist_pick_from_library))
}
}
if (localPlaylist.isNotEmpty()) {
OutlinedButton(
onClick = { localPlaylist = emptyList() },
@@ -266,17 +316,165 @@ fun BegleitmusikDialog(
}
}
// ─── Stream-URL ───────────────────────────────────────────────
// ─── Stream-Playlist ──────────────────────────────────────────
if (sourceType == MusicSourceType.STREAM_URL) {
Text(
stringResource(R.string.music_stream_playlist_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (streamPlaylist.isEmpty()) {
Text(
stringResource(R.string.music_stream_playlist_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
streamPlaylist.forEachIndexed { idx, entry ->
val displayLabel = entry.name.ifBlank { entry.url }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.Radio,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = displayLabel,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (entry.name.isNotBlank() && entry.url.isNotBlank()) {
Text(
text = entry.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(
onClick = {
if (idx > 0) {
streamPlaylist = streamPlaylist.toMutableList().also { list ->
val tmp = list[idx - 1]
list[idx - 1] = list[idx]
list[idx] = tmp
}
}
},
enabled = idx > 0,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(R.string.music_stream_move_up),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
if (idx < streamPlaylist.size - 1) {
streamPlaylist = streamPlaylist.toMutableList().also { list ->
val tmp = list[idx + 1]
list[idx + 1] = list[idx]
list[idx] = tmp
}
}
},
enabled = idx < streamPlaylist.size - 1,
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.KeyboardArrowDown,
contentDescription = stringResource(R.string.music_stream_move_down),
modifier = Modifier.size(18.dp)
)
}
IconButton(
onClick = {
editingStreamIndex = idx
newStreamName = entry.name
newStreamUrl = entry.url
newStreamError = ""
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.music_stream_edit),
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = {
streamPlaylist = streamPlaylist.toMutableList()
.also { it.removeAt(idx) }
if (editingStreamIndex == idx) {
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
} else if (editingStreamIndex > idx) {
editingStreamIndex -= 1
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.music_stream_remove),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
// Anzeige des Stream-Playlist-Validierungsfehlers (z. B. leer beim Save)
if (streamUrlError.isNotBlank()) {
Text(
streamUrlError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ─── Eingabe: neuer / editierter Stream ───────────────────
val editing = editingStreamIndex in streamPlaylist.indices
Text(
if (editing) stringResource(R.string.music_stream_edit_section_label)
else stringResource(R.string.music_stream_add_section_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streamUrl,
onValueChange = { streamUrl = it; streamUrlError = "" },
value = newStreamName,
onValueChange = { newStreamName = it },
label = { Text(stringResource(R.string.music_stream_name_label)) },
placeholder = { Text(stringResource(R.string.music_stream_name_placeholder)) },
singleLine = true,
leadingIcon = { Icon(Icons.Filled.Label, null) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = newStreamUrl,
onValueChange = { newStreamUrl = it; newStreamError = "" },
label = { Text(stringResource(R.string.music_stream_url_label)) },
placeholder = { Text("https://stream.example.com/radio") },
isError = streamUrlError.isNotBlank(),
isError = newStreamError.isNotBlank(),
supportingText = {
if (streamUrlError.isNotBlank()) {
Text(streamUrlError, color = MaterialTheme.colorScheme.error)
if (newStreamError.isNotBlank()) {
Text(newStreamError, color = MaterialTheme.colorScheme.error)
} else {
Text(
stringResource(R.string.music_stream_url_hint),
@@ -288,6 +486,89 @@ fun BegleitmusikDialog(
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
val err = validateStreamUrlInput(newStreamUrl)
if (err != null) {
newStreamError = err
return@OutlinedButton
}
val entry = StreamEntry(
url = newStreamUrl.trim(),
name = newStreamName.trim()
)
streamPlaylist = if (editing) {
streamPlaylist.toMutableList().also { list ->
list[editingStreamIndex] = entry
}
} else {
streamPlaylist + entry
}
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
streamUrlError = ""
}
) {
Icon(
if (editing) Icons.Filled.Check else Icons.Filled.Add,
null, Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
if (editing) stringResource(R.string.music_stream_save_entry)
else stringResource(R.string.music_stream_add_entry)
)
}
if (editing) {
OutlinedButton(
onClick = {
editingStreamIndex = -1
newStreamName = ""
newStreamUrl = ""
newStreamError = ""
}
) {
Text(stringResource(R.string.cancel))
}
}
if (streamPlaylist.isNotEmpty()) {
OutlinedButton(
onClick = {
streamPlaylist = emptyList()
editingStreamIndex = -1
},
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.music_stream_clear))
}
}
}
// Shuffle auch für Stream-Playlist erlauben (sinnvoll bei
// mehreren Sendern, die in zufälliger Reihenfolge laufen sollen)
if (streamPlaylist.size > 1) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.music_shuffle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(checked = shuffle, onCheckedChange = { shuffle = it })
}
}
// Hinweis zu YouTube/SoundCloud/radio.de
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
@@ -456,6 +737,135 @@ fun BegleitmusikDialog(
}
}
)
// Sub-Dialog: Bekannte Audiodateien aus der Bibliothek auswählen.
if (showLibraryPicker) {
LibraryPickerDialog(
library = audioLibrary,
alreadySelectedUris = localPlaylist.map { it.uriString }.toSet(),
onDismiss = { showLibraryPicker = false },
onConfirm = { picked ->
if (picked.isNotEmpty()) {
// URI-Berechtigungen erneut sichern (idempotent für bereits
// freigegebene URIs; verhindert Lese-Fehler nach App-Restart)
picked.forEach { item ->
runCatching {
context.contentResolver.takePersistableUriPermission(
Uri.parse(item.uriString),
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
localPlaylist = (localPlaylist + picked).distinctBy { it.uriString }
}
showLibraryPicker = false
}
)
}
}
/**
* Sub-Dialog zur Auswahl von Audiodateien aus der tour-übergreifenden Audio-Bibliothek.
*
* Zeigt alle bekannten Audiodateien (aus Wegpunkten und allen Begleitmusik-Playlists),
* mit Häkchen für die Auswahl. Bereits in der aktuellen Playlist befindliche Dateien
* sind vorab angekreuzt und können hier nicht entfernt werden nur hinzukommende
* Auswahl wird übergeben.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LibraryPickerDialog(
library: List<de.waypointaudio.data.PlaylistItem>,
alreadySelectedUris: Set<String>,
onDismiss: () -> Unit,
onConfirm: (List<de.waypointaudio.data.PlaylistItem>) -> Unit
) {
// Nur Dateien anzeigen, die noch nicht in der aktuellen Playlist sind
val available = remember(library, alreadySelectedUris) {
library.filter { it.uriString !in alreadySelectedUris }
}
val checkedUris = remember { mutableStateListOf<String>() }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(R.string.music_library_picker_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.music_library_picker_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (available.isEmpty()) {
Text(
stringResource(R.string.music_library_picker_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
} else {
available.forEach { item ->
val isChecked = item.uriString in checkedUris
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = isChecked,
onCheckedChange = { c ->
if (c) {
if (item.uriString !in checkedUris) checkedUris += item.uriString
} else {
checkedUris.remove(item.uriString)
}
}
)
Icon(
Icons.Filled.AudioFile,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(6.dp))
Text(
text = item.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
},
confirmButton = {
TextButton(
enabled = checkedUris.isNotEmpty(),
onClick = {
val picked = available.filter { it.uriString in checkedUris }
onConfirm(picked)
}
) {
Text(stringResource(R.string.music_library_picker_add))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable