Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,476 @@
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.Close
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.Surface
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.service.AudioDeviceItem
import de.waypointaudio.service.LivePttManager
/**
* Karte für Live / PTT auf dem Hauptbildschirm.
* Platzierung: zwischen Atmo-Mini-Player und Wegpunkt-Tracks-Überschrift.
*
* Features:
* - PTT starten / stoppen (Toggle-Button)
* - Statusanzeige (Bereit / Mikrofon aktiv / Berechtigung fehlt)
* - Echo-Warnung bei fehlendem Headset
* - Button zum Öffnen der Audio-Geräte-Einstellungen
* - Gerätewahl-Kurzanzeige (gewählte Geräte-Namen)
*/
@Composable
fun LivePttCard(
pttManager: LivePttManager,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val pttActive by pttManager.pttActive.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()
}
}
// Geräte laden wenn Karte angezeigt wird
LaunchedEffect(Unit) {
pttManager.refreshDevices()
}
// Farbanimation für PTT-Button
val pttContainerColor by animateColorAsState(
targetValue = if (pttActive)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary,
animationSpec = tween(300),
label = "pttColor"
)
// Anzeige-Name des gewählten Eingabegeräts
val inputDeviceName = remember(routing.selectedInputDeviceId, inputDevices) {
if (routing.selectedInputDeviceId == null) null
else inputDevices.firstOrNull { it.id == routing.selectedInputDeviceId }?.displayName
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
// ── Kopfzeile: Titel + Geräte-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)
)
// Gerätewahl-Button
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)
pttActive -> stringResource(R.string.ptt_status_active)
statusMsg != null -> statusMsg!!
else -> stringResource(R.string.ptt_status_ready)
}
Row(verticalAlignment = Alignment.CenterVertically) {
// Status-Indikator (runder Punkt)
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
}
)
}
// Gewähltes Gerät anzeigen (falls nicht Systemstandard)
if (inputDeviceName != null) {
Spacer(Modifier.height(4.dp))
Text(
text = "🎤 $inputDeviceName",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Berechtigung verweigert Hinweis
if (showPermissionDeniedHint) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.ptt_permission_denied_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
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
)
}
// Echo-Warnung (immer sichtbar wenn kein Headset konfiguriert)
val noHeadset = routing.selectedOutputDeviceId == null &&
routing.selectedInputDeviceId == null
if (noHeadset) {
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.ptt_echo_warning),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
// ── Audio-Geräte-Dialog ───────────────────────────────────────────────────
if (showDeviceDialog) {
AudioDeviceDialog(
pttManager = pttManager,
onDismiss = { showDeviceDialog = false }
)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Audio-Geräte-Dialog
// ─────────────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudioDeviceDialog(
pttManager: LivePttManager,
onDismiss: () -> Unit
) {
val inputDevices by pttManager.inputDevices.collectAsState()
val outputDevices by pttManager.outputDevices.collectAsState()
val routing by pttManager.routingSettings.collectAsState()
// Lokale Auswahl-States
var selectedInputId by remember(routing) { mutableStateOf(routing.selectedInputDeviceId) }
var selectedOutputId by remember(routing) { mutableStateOf(routing.selectedOutputDeviceId) }
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(16.dp)
) {
// ── Refresh-Button ────────────────────────────────────────────
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)
}
// ── Eingabegerät (Mikrofon) ───────────────────────────────────
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 }
) {
// Systemstandard
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
}
)
}
}
}
}
// ── Ausgabegerät (Lautsprecher) ───────────────────────────────
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 }
) {
// Systemstandard
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
}
)
}
}
}
}
// ── Hinweis zu Geräte-IDs ─────────────────────────────────────
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
)
)
onDismiss()
}) {
Text(stringResource(R.string.audio_devices_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}