894 lines
45 KiB
Kotlin
894 lines
45 KiB
Kotlin
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)
|
||
)
|
||
}
|
||
}
|
||
}
|