package de.waypointaudio.ui import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import de.waypointaudio.R import de.waypointaudio.data.AudioRoutingSettings import de.waypointaudio.data.PttRecordingFormat import de.waypointaudio.service.LivePttManager /** * Karte für Live / PTT auf dem Hauptbildschirm. * * v2.1.0: * - Toggle „Live-Ausgabe" und „Aufnahme speichern" (mit Format M4A/AAC). * - Aufnahme-Indikator (rotes Punkt) im aktiven Zustand. * - Knopf „Aufnahmen…" öffnet die Bibliothek; Post-Aufnahme-Dialog erscheint * automatisch nach jeder gespeicherten Aufnahme. * * @param pttManager Singleton-Manager * @param onOpenRecordings Callback um die Aufnahme-Bibliothek zu öffnen */ @Composable fun LivePttCard( pttManager: LivePttManager, onOpenRecordings: () -> Unit = {}, modifier: Modifier = Modifier ) { val context = LocalContext.current val pttActive by pttManager.pttActive.collectAsState() val recordingActive by pttManager.recordingActive.collectAsState() val routing by pttManager.routingSettings.collectAsState() val inputDevices by pttManager.inputDevices.collectAsState() val statusMsg by pttManager.statusMessage.collectAsState() var hasMicPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED ) } var showPermissionDeniedHint by remember { mutableStateOf(false) } var showDeviceDialog by remember { mutableStateOf(false) } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> hasMicPermission = granted if (!granted) { showPermissionDeniedHint = true } else { pttManager.startPtt() } } LaunchedEffect(Unit) { pttManager.refreshDevices() } val pttContainerColor by animateColorAsState( targetValue = if (pttActive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, animationSpec = tween(300), label = "pttColor" ) val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) { if (routing.selectedInputDeviceId == null) null else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName } // v2.5.1 — Card mit sanften Cornern und größerem Innenabstand für // einen ruhigeren Look in der GPS2Audio-Farbwelt. Card( modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), shape = androidx.compose.foundation.shape.RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { // ── Kopfzeile: Titel + Aufnahmen-Button + Einstellungen-Button ─── Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = if (pttActive) Icons.Filled.Mic else Icons.Filled.MicOff, contentDescription = null, tint = if (pttActive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp) ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.ptt_card_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f) ) IconButton(onClick = onOpenRecordings) { Icon( Icons.Filled.LibraryMusic, contentDescription = stringResource(R.string.ptt_recordings_button), tint = MaterialTheme.colorScheme.primary ) } IconButton( onClick = { pttManager.refreshDevices() showDeviceDialog = true } ) { Icon( Icons.Filled.Settings, contentDescription = stringResource(R.string.ptt_audio_devices_button), tint = MaterialTheme.colorScheme.primary ) } } Spacer(Modifier.height(4.dp)) // ── Status-Zeile ────────────────────────────────────────────────── val statusText = when { !hasMicPermission -> stringResource(R.string.ptt_status_permission_missing) recordingActive -> stringResource(R.string.ptt_recording_status) pttActive -> stringResource(R.string.ptt_status_active) statusMsg != null -> statusMsg!! else -> stringResource(R.string.ptt_status_ready) } Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(8.dp) .clip(CircleShape) .background( if (pttActive) MaterialTheme.colorScheme.error else if (!hasMicPermission) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) ) ) Spacer(Modifier.width(6.dp)) Text( text = statusText, style = MaterialTheme.typography.bodySmall, color = when { pttActive -> MaterialTheme.colorScheme.error !hasMicPermission -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.onSurfaceVariant } ) if (recordingActive) { Spacer(Modifier.width(8.dp)) Icon( imageVector = Icons.Filled.FiberManualRecord, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(12.dp) ) } } if (inputDeviceName != null) { Spacer(Modifier.height(4.dp)) Text( text = "🎤 $inputDeviceName", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } if (showPermissionDeniedHint) { Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.ptt_permission_denied_hint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) } // Mode summary (Live / Aufnahme / beides) Spacer(Modifier.height(4.dp)) val modeText = when { routing.liveOutputEnabled && routing.recordingEnabled -> "Live + Aufnahme" routing.recordingEnabled -> "Nur Aufnahme" else -> "Nur Live" } Text( text = modeText, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) Spacer(Modifier.height(6.dp)) // ── PTT-Hauptbutton ─────────────────────────────────────────────── Button( onClick = { if (!hasMicPermission) { showPermissionDeniedHint = false permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } else { pttManager.togglePtt() } }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = pttContainerColor) ) { Icon( imageVector = if (pttActive) Icons.Filled.MicOff else Icons.Filled.Mic, contentDescription = null, modifier = Modifier.size(18.dp) ) Spacer(Modifier.width(8.dp)) Text( text = if (pttActive) stringResource(R.string.ptt_stop) else stringResource(R.string.ptt_start), style = MaterialTheme.typography.labelLarge ) } val noHeadset = routing.selectedOutputDeviceId == null && routing.selectedInputDeviceId == null if (noHeadset && routing.liveOutputEnabled) { Spacer(Modifier.height(6.dp)) Text( text = stringResource(R.string.ptt_echo_warning), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } } } if (showDeviceDialog) { PttSettingsDialog( pttManager = pttManager, onDismiss = { showDeviceDialog = false } ) } } // ───────────────────────────────────────────────────────────────────────────── // PTT-Einstellungen Dialog (Live/Aufnahme + Geräte) // ───────────────────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @Composable fun PttSettingsDialog( pttManager: LivePttManager, onDismiss: () -> Unit ) { val inputDevices by pttManager.inputDevices.collectAsState() val outputDevices by pttManager.outputDevices.collectAsState() val routing by pttManager.routingSettings.collectAsState() var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) } var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) } var liveEnabled by remember(routing) { mutableStateOf(routing.liveOutputEnabled) } var recordEnabled by remember(routing) { mutableStateOf(routing.recordingEnabled) } var format by remember(routing) { mutableStateOf(routing.recordingFormat) } var validationHint by remember { mutableStateOf(null) } var inputExpanded by remember { mutableStateOf(false) } var outputExpanded by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, title = { Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.audio_devices_dialog_title)) } }, text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // ── Live / Aufnahme-Modus ───────────────────────────────────── Text( text = stringResource(R.string.ptt_settings_section), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.ptt_live_output_toggle), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(R.string.ptt_live_output_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = liveEnabled, onCheckedChange = { v -> liveEnabled = v validationHint = null if (!v && !recordEnabled) { liveEnabled = true validationHint = "Mindestens eine Option aktiv lassen." } } ) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.ptt_recording_toggle), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold ) Text( text = stringResource(R.string.ptt_recording_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = recordEnabled, onCheckedChange = { v -> recordEnabled = v validationHint = null if (!v && !liveEnabled) { recordEnabled = true validationHint = "Mindestens eine Option aktiv lassen." } } ) } validationHint?.let { hint -> Text( text = hint, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) } // Format (M4A only, WAV disabled) if (recordEnabled) { Text( text = stringResource(R.string.ptt_format_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) Row(verticalAlignment = Alignment.CenterVertically) { RadioButton( selected = format == PttRecordingFormat.M4A_AAC, onClick = { format = PttRecordingFormat.M4A_AAC } ) Text(stringResource(R.string.ptt_format_m4a)) } Row(verticalAlignment = Alignment.CenterVertically) { RadioButton( selected = false, onClick = { /* WAV deaktiviert in v2.1.0 */ }, enabled = false ) Text( stringResource(R.string.ptt_format_wav), color = MaterialTheme.colorScheme.outline ) } } HorizontalDivider() // ── Geräteauswahl ───────────────────────────────────────────── TextButton( onClick = { pttManager.refreshDevices() }, modifier = Modifier.align(Alignment.End) ) { Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) Text(stringResource(R.string.audio_devices_refresh), style = MaterialTheme.typography.labelMedium) } Text( text = stringResource(R.string.audio_input_device_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) if (inputDevices.isEmpty()) { Text( text = stringResource(R.string.audio_devices_no_input), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) } else { ExposedDropdownMenuBox( expanded = inputExpanded, onExpandedChange = { inputExpanded = it } ) { OutlinedTextField( value = selectedInputId?.let { id -> inputDevices.firstOrNull { it.id == id }?.displayName ?: stringResource(R.string.ptt_device_system_default) } ?: stringResource(R.string.ptt_device_system_default), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.ptt_input_label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(inputExpanded) }, modifier = Modifier .menuAnchor(MenuAnchorType.PrimaryNotEditable) .fillMaxWidth() ) ExposedDropdownMenu( expanded = inputExpanded, onDismissRequest = { inputExpanded = false } ) { DropdownMenuItem( text = { Text(stringResource(R.string.ptt_device_system_default)) }, onClick = { selectedInputId = null inputExpanded = false } ) HorizontalDivider() inputDevices.forEach { device -> DropdownMenuItem( text = { Text(device.displayName) }, onClick = { selectedInputId = device.id inputExpanded = false } ) } } } } Text( text = stringResource(R.string.audio_output_device_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) if (outputDevices.isEmpty()) { Text( text = stringResource(R.string.audio_devices_no_output), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) } else { ExposedDropdownMenuBox( expanded = outputExpanded, onExpandedChange = { outputExpanded = it } ) { OutlinedTextField( value = selectedOutputId?.let { id -> outputDevices.firstOrNull { it.id == id }?.displayName ?: stringResource(R.string.ptt_device_system_default) } ?: stringResource(R.string.ptt_device_system_default), onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.ptt_output_label)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(outputExpanded) }, modifier = Modifier .menuAnchor(MenuAnchorType.PrimaryNotEditable) .fillMaxWidth() ) ExposedDropdownMenu( expanded = outputExpanded, onDismissRequest = { outputExpanded = false } ) { DropdownMenuItem( text = { Text(stringResource(R.string.ptt_device_system_default)) }, onClick = { selectedOutputId = null outputExpanded = false } ) HorizontalDivider() outputDevices.forEach { device -> DropdownMenuItem( text = { Text(device.displayName) }, onClick = { selectedOutputId = device.id outputExpanded = false } ) } } } } HorizontalDivider() Text( text = stringResource(R.string.audio_devices_hint), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline ) } }, confirmButton = { Button(onClick = { pttManager.saveRoutingSettings( AudioRoutingSettings( selectedInputDeviceId = selectedInputId, selectedOutputDeviceId = selectedOutputId, liveOutputEnabled = liveEnabled, recordingEnabled = recordEnabled, recordingFormat = format ) ) onDismiss() }) { Text(stringResource(R.string.audio_devices_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } } ) }