Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt
T
2026-05-30 05:42:44 +00:00

894 lines
45 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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
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.StreamEntry
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, ExperimentalLayoutApi::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()
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) }
// 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(
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) {
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,
streamPlaylist = streamPlaylist,
streamUrl = null,
behavior = behavior,
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
shuffle = shuffle,
autoStartAfterWaypoint = autoStartAfterWaypoint
)
viewModel.saveMusicSettings(settings)
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 = {
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()) {
// 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(
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.6f)
)
} 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
)
}
}
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth()
) {
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))
}
// 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() },
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-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 = 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 = newStreamError.isNotBlank(),
supportingText = {
if (newStreamError.isNotBlank()) {
Text(newStreamError, 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()
)
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,
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))
}
}
)
// 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
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)
)
}
}
}