Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,483 @@
package de.waypointaudio.ui
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import de.waypointaudio.viewmodel.WaypointViewModel
/**
* Vollbilddialog (AlertDialog mit Scroll) für Begleitmusik-Einstellungen einer Tour.
*
* Ö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)
@Composable
fun BegleitmusikDialog(
tourName: String,
viewModel: WaypointViewModel,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val initialSettings by viewModel.musicSettings.collectAsState()
val musicPlaying by viewModel.musicPlaying.collectAsState()
// 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 ?: "") }
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) }
var streamUrlError by remember { mutableStateOf("") }
// Launcher für mehrere Audiodateien
val multiPickLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
// URI-Berechtigungen dauerhaft sichern
uris.forEach { uri ->
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
val newItems = uris.map { uri ->
val name = runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIdx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
cursor.moveToFirst()
if (nameIdx >= 0) cursor.getString(nameIdx) else uri.lastPathSegment ?: uri.toString()
}
}.getOrNull() ?: uri.lastPathSegment ?: uri.toString()
PlaylistItem(uri.toString(), name)
}
localPlaylist = (localPlaylist + newItems).distinctBy { it.uriString }
}
}
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)
return false
}
}
streamUrlError = ""
val settings = TourAudioSettings(
enabled = enabled,
sourceType = sourceType,
localPlaylist = localPlaylist,
streamUrl = streamUrl.trim().ifBlank { null },
behavior = behavior,
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
shuffle = shuffle,
autoStartAfterWaypoint = autoStartAfterWaypoint
)
viewModel.saveMusicSettings(settings)
return true
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(R.string.music_title),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Tour-Kontext
Text(
stringResource(R.string.music_for_tour, tourName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// ─── Aktivieren/Deaktivieren ─────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.music_enable),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Switch(checked = enabled, onCheckedChange = { enabled = it })
}
HorizontalDivider()
if (enabled) {
// ─── Quelltyp ─────────────────────────────────────────────────
Text(
stringResource(R.string.music_source_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = sourceType == MusicSourceType.LOCAL_PLAYLIST,
onClick = { sourceType = MusicSourceType.LOCAL_PLAYLIST },
label = { Text(stringResource(R.string.music_source_local)) },
leadingIcon = if (sourceType == MusicSourceType.LOCAL_PLAYLIST) {
{ Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) }
} else null
)
FilterChip(
selected = sourceType == MusicSourceType.STREAM_URL,
onClick = { sourceType = MusicSourceType.STREAM_URL },
label = { Text(stringResource(R.string.music_source_stream)) },
leadingIcon = if (sourceType == MusicSourceType.STREAM_URL) {
{ Icon(Icons.Filled.Check, null, Modifier.size(16.dp)) }
} else null
)
}
// ─── Lokale Playlist ──────────────────────────────────────────
if (sourceType == MusicSourceType.LOCAL_PLAYLIST) {
Text(
stringResource(R.string.music_playlist_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (localPlaylist.isEmpty()) {
Text(
stringResource(R.string.music_playlist_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
} else {
localPlaylist.forEachIndexed { idx, item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
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.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
IconButton(
onClick = {
localPlaylist = localPlaylist.toMutableList()
.also { it.removeAt(idx) }
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.music_playlist_remove),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
multiPickLauncher.launch(
arrayOf("audio/*", "audio/mpeg", "audio/ogg", "audio/flac", "*/*")
)
}
) {
Icon(Icons.Filled.Add, null, Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.music_playlist_add))
}
if (localPlaylist.isNotEmpty()) {
OutlinedButton(
onClick = { localPlaylist = emptyList() },
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.music_playlist_clear))
}
}
}
// Shuffle
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 })
}
}
// ─── Stream-URL ───────────────────────────────────────────────
if (sourceType == MusicSourceType.STREAM_URL) {
OutlinedTextField(
value = streamUrl,
onValueChange = { streamUrl = it; streamUrlError = "" },
label = { Text(stringResource(R.string.music_stream_url_label)) },
placeholder = { Text("https://stream.example.com/radio") },
isError = streamUrlError.isNotBlank(),
supportingText = {
if (streamUrlError.isNotBlank()) {
Text(streamUrlError, color = MaterialTheme.colorScheme.error)
} else {
Text(
stringResource(R.string.music_stream_url_hint),
style = MaterialTheme.typography.bodySmall
)
}
},
leadingIcon = { Icon(Icons.Filled.Radio, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Hinweis zu YouTube/SoundCloud/radio.de
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Filled.Info,
contentDescription = null,
modifier = Modifier.size(16.dp).padding(top = 2.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
stringResource(R.string.music_stream_platform_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
HorizontalDivider()
// ─── Verhalten beim Wegpunkt ──────────────────────────────────
Text(
stringResource(R.string.music_behavior_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
BehaviorOption(
selected = behavior == WaypointMusicBehavior.PAUSE_RESUME,
title = stringResource(R.string.music_behavior_pause_resume),
description = stringResource(R.string.music_behavior_pause_resume_desc),
onClick = { behavior = WaypointMusicBehavior.PAUSE_RESUME }
)
BehaviorOption(
selected = behavior == WaypointMusicBehavior.FADE_OUT_IN,
title = stringResource(R.string.music_behavior_fade),
description = stringResource(R.string.music_behavior_fade_desc),
onClick = { behavior = WaypointMusicBehavior.FADE_OUT_IN }
)
BehaviorOption(
selected = behavior == WaypointMusicBehavior.DUCK_UNDERLAY,
title = stringResource(R.string.music_behavior_duck),
description = stringResource(R.string.music_behavior_duck_desc),
onClick = { behavior = WaypointMusicBehavior.DUCK_UNDERLAY }
)
BehaviorOption(
selected = behavior == WaypointMusicBehavior.CONTINUE_UNDERLAY,
title = stringResource(R.string.music_behavior_continue),
description = stringResource(R.string.music_behavior_continue_desc),
onClick = { behavior = WaypointMusicBehavior.CONTINUE_UNDERLAY }
)
// ─── Fade-Dauer (nur relevant für FADE_OUT_IN und DUCK) ───────
if (behavior == WaypointMusicBehavior.FADE_OUT_IN ||
behavior == WaypointMusicBehavior.DUCK_UNDERLAY
) {
HorizontalDivider()
Text(
stringResource(R.string.music_fade_duration_label, fadeDurationMs),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = fadeDurationMs.toFloat(),
onValueChange = { fadeDurationMs = it.toLong() },
valueRange = 300f..4000f,
steps = 11,
modifier = Modifier.fillMaxWidth()
)
}
// ─── Duck-Lautstärke ──────────────────────────────────────────
if (behavior == WaypointMusicBehavior.DUCK_UNDERLAY) {
Text(
stringResource(R.string.music_duck_volume_label, (duckVolume * 100).toInt()),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = duckVolume,
onValueChange = { duckVolume = it },
valueRange = 0.05f..0.5f,
modifier = Modifier.fillMaxWidth()
)
}
HorizontalDivider()
// ─── Autostart nach Wegpunkt ──────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.music_autostart_label),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
stringResource(R.string.music_autostart_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
Switch(
checked = autoStartAfterWaypoint,
onCheckedChange = { autoStartAfterWaypoint = it }
)
}
HorizontalDivider()
// ─── Test-Steuerung ───────────────────────────────────────────
Text(
stringResource(R.string.music_test_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledIconButton(
onClick = { viewModel.toggleMusicPlayback() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
if (musicPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = stringResource(R.string.music_test_play_pause)
)
}
IconButton(onClick = { viewModel.stopMusicPlayback() }) {
Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.music_test_stop))
}
IconButton(onClick = { viewModel.musicManager.player.previous() }) {
Icon(Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.manual_previous))
}
IconButton(onClick = { viewModel.musicManager.player.next() }) {
Icon(Icons.Filled.SkipNext, contentDescription = stringResource(R.string.manual_next))
}
}
}
}
},
confirmButton = {
TextButton(onClick = {
if (validateAndSave()) onDismiss()
}) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = {
viewModel.stopMusicPlayback()
onDismiss()
}) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
private fun BehaviorOption(
selected: Boolean,
title: String,
description: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
RadioButton(selected = selected, onClick = onClick)
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}