Initial release of GPS2Audio
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user