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 -> 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, alreadySelectedUris: Set, onDismiss: () -> Unit, onConfirm: (List) -> 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() } 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) ) } } }