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
+64
View File
@@ -0,0 +1,64 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "de.waypointaudio"
compileSdk = 35
defaultConfig {
applicationId = "de.waypointaudio"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.gson)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.play.services.location)
implementation(libs.osmdroid)
implementation(libs.media3.exoplayer)
implementation(libs.media3.common)
debugImplementation(libs.androidx.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Add project specific ProGuard rules here.
-keep class de.waypointaudio.data.** { *; }
+91
View File
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Location Permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Background location for waypoint detection when app is not in foreground -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Foreground Service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Microphone for Live/PTT -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Foreground service with microphone access (Android 14+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- Audio (MediaPlayer needs no special permission for local files) -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Fallback for Android < 13 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Internet for osmdroid tile downloads -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Keep CPU alive while playing audio in service -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Vibrate for notification (optional) -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".WaypointApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.WaypointAudioGuide">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground location service (Wegpunkt-Erkennung) -->
<service
android:name=".service.WaypointLocationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Foreground location service (GPS-Track-Aufzeichnung im Hintergrund) -->
<service
android:name=".service.TrackRecordingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<!-- Live/PTT Microphone Service -->
<service
android:name=".service.LivePttService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone" />
<!-- File provider for sound file selection (Android 7+) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,172 @@
package de.waypointaudio
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.core.content.ContextCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.waypointaudio.ui.MapScreen
import de.waypointaudio.ui.PermissionRequiredScreen
import de.waypointaudio.ui.WaypointListScreen
import de.waypointaudio.ui.theme.WaypointAudioTheme
import de.waypointaudio.viewmodel.WaypointViewModel
/**
* Hauptaktivität der App.
*
* Navigation:
* "list" → WaypointListScreen (Startbildschirm)
* "map" → MapScreen (osmdroid Kartenansicht)
*
* Berechtigungslogik:
* - Nur ACCESS_FINE_LOCATION und ACCESS_COARSE_LOCATION sind zwingend erforderlich
* (für Standort-Button und Karte). Ohne diese wird der PermissionScreen gezeigt.
* - POST_NOTIFICATIONS und Audiozugriff werden separat angefragt, blockieren
* aber nicht die Hauptoberfläche.
*/
class MainActivity : ComponentActivity() {
private val viewModel: WaypointViewModel by viewModels()
/** Nur Standortberechtigungen steuern, ob die App-UI angezeigt wird. */
private val locationPermissionsGranted = mutableStateOf(false)
// Launcher für Standortberechtigungen (zwingend erforderlich)
private val locationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
// Mindestens ACCESS_COARSE_LOCATION muss gewährt sein
val coarseGranted = results[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val fineGranted = results[Manifest.permission.ACCESS_FINE_LOCATION] == true
locationPermissionsGranted.value = coarseGranted || fineGranted
// Nach Gewährung von Standort: optionale Berechtigungen separat anfragen
if (locationPermissionsGranted.value) {
requestOptionalPermissions()
}
}
// Launcher für optionale Berechtigungen (Benachrichtigungen, Audio)
private val optionalPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { /* optional kein Block der Hauptoberfläche */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Standortberechtigungen prüfen und ggf. anfordern
checkAndRequestLocationPermissions()
setContent {
WaypointAudioTheme(darkTheme = isSystemInDarkTheme()) {
val locationGranted by locationPermissionsGranted
if (locationGranted) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "list"
) {
composable("list") {
WaypointListScreen(
viewModel = viewModel,
onNavigateToMap = { navController.navigate("map") }
)
}
composable("map") {
MapScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
}
} else {
PermissionRequiredScreen(
onPermissionsGranted = { checkAndRequestLocationPermissions() }
)
}
}
}
}
override fun onResume() {
super.onResume()
// Beim Zurückkehren aus den Einstellungen erneut prüfen
locationPermissionsGranted.value = hasLocationPermission()
if (locationPermissionsGranted.value) {
requestOptionalPermissions()
}
}
// ---------------------------------------------------------------------------
// Berechtigungslogik
// ---------------------------------------------------------------------------
private fun checkAndRequestLocationPermissions() {
if (hasLocationPermission()) {
locationPermissionsGranted.value = true
requestOptionalPermissions()
} else {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
}
/** Prüft ob mindestens grobe oder feine Standortberechtigung vorhanden ist. */
private fun hasLocationPermission(): Boolean {
val fine = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val coarse = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
return fine || coarse
}
/**
* Optionale Berechtigungen (Benachrichtigungen, Audiozugriff) anfragen,
* nachdem Standortberechtigung gewährt wurde.
* Diese blockieren NICHT die Haupt-UI.
*/
private fun requestOptionalPermissions() {
val toRequest = buildList {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.READ_MEDIA_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.READ_MEDIA_AUDIO)
}
} else {
if (ContextCompat.checkSelfPermission(
this@MainActivity, Manifest.permission.READ_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
if (toRequest.isNotEmpty()) {
optionalPermissionLauncher.launch(toRequest.toTypedArray())
}
}
}
@@ -0,0 +1,29 @@
package de.waypointaudio
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
/**
* Application-Klasse initialisiert globale Ressourcen.
*/
class WaypointApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
private fun createNotificationChannels() {
val channel = NotificationChannel(
"waypoint_service_channel",
"Wegpunkt-Ortungsdienst",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Läuft im Hintergrund, um Wegpunkte zu erkennen"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
@@ -0,0 +1,67 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Globale Audio-Routing-Einstellungen für Live/PTT.
*
* Gerätekennungen (AudioDeviceInfo.id) sind Sitzungs-spezifisch und können sich nach
* einem Neustart oder nach dem Trennen/Verbinden von Bluetooth-Geräten ändern.
* Wenn ein gespeichertes Gerät nicht mehr verfügbar ist, wird automatisch der
* Systemstandard verwendet.
*
* @param selectedInputDeviceId ID des Eingabegeräts, null = Systemstandard
* @param selectedOutputDeviceId ID des Ausgabegeräts, null = Systemstandard
*/
data class AudioRoutingSettings(
val selectedInputDeviceId: Int? = null,
val selectedOutputDeviceId: Int? = null
)
private val Context.audioRoutingDataStore by preferencesDataStore(name = "audio_routing_settings")
/**
* Persistenz-Schicht für Audio-Routing-Einstellungen via DataStore.
*/
class AudioRoutingStore(private val context: Context) {
private companion object {
val KEY_INPUT_DEVICE_ID = intPreferencesKey("selected_input_device_id")
val KEY_OUTPUT_DEVICE_ID = intPreferencesKey("selected_output_device_id")
// Sentinel: -1 means "use system default" (null cannot be stored as Int)
const val NO_DEVICE = -1
}
val settings: Flow<AudioRoutingSettings> = context.audioRoutingDataStore.data
.map { prefs ->
AudioRoutingSettings(
selectedInputDeviceId = prefs[KEY_INPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE },
selectedOutputDeviceId = prefs[KEY_OUTPUT_DEVICE_ID].takeIf { it != null && it != NO_DEVICE }
)
}
suspend fun save(settings: AudioRoutingSettings) {
context.audioRoutingDataStore.edit { prefs ->
prefs[KEY_INPUT_DEVICE_ID] = settings.selectedInputDeviceId ?: NO_DEVICE
prefs[KEY_OUTPUT_DEVICE_ID] = settings.selectedOutputDeviceId ?: NO_DEVICE
}
}
suspend fun clearInputDevice() {
context.audioRoutingDataStore.edit { prefs ->
prefs[KEY_INPUT_DEVICE_ID] = NO_DEVICE
}
}
suspend fun clearOutputDevice() {
context.audioRoutingDataStore.edit { prefs ->
prefs[KEY_OUTPUT_DEVICE_ID] = NO_DEVICE
}
}
}
@@ -0,0 +1,14 @@
package de.waypointaudio.data
/**
* Ein einzelner Punkt eines aufgezeichneten GPS-Tracks.
*
* @param latitude Breitengrad in Dezimalgrad
* @param longitude Längengrad in Dezimalgrad
* @param timestamp Unix-Millisekunden zum Zeitpunkt der Aufzeichnung
*/
data class GpsTrackPoint(
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val timestamp: Long = System.currentTimeMillis()
)
@@ -0,0 +1,60 @@
package de.waypointaudio.data
/**
* Quelle der Begleitmusik für eine Tour.
*/
enum class MusicSourceType {
LOCAL_PLAYLIST,
STREAM_URL
}
/**
* Verhalten der Begleitmusik, wenn ein Wegpunkt-Audio abgespielt wird.
*/
enum class WaypointMusicBehavior {
/** Begleitmusik pausieren, danach fortsetzen. */
PAUSE_RESUME,
/** Begleitmusik ausblenden (Fade-out), Wegpunkt abspielen, danach einblenden (Fade-in). */
FADE_OUT_IN,
/** Begleitmusik auf duckVolume leise stellen, Wegpunkt parallel abspielen, danach wiederherstellen. */
DUCK_UNDERLAY,
/** Begleitmusik unveränderter Lautstärke weiterspielen, Wegpunkt parallel abspielen. */
CONTINUE_UNDERLAY
}
/**
* Ein einzelnes Element in der lokalen Playlist.
*/
data class PlaylistItem(
val uriString: String,
val displayName: String
)
/**
* Begleitmusik-Einstellungen für eine Tour.
*
* @param enabled Ob Begleitmusik für diese Tour aktiv ist.
* @param sourceType Quelle: lokale Playlist oder Stream-URL.
* @param localPlaylist Liste von Audiodateien (content:// URIs) aus dem lokalen Speicher.
* @param streamUrl Direkte HTTP/HTTPS-Stream-URL (z. B. Internetradio).
* @param behavior Verhalten wenn Wegpunkt-Audio abgespielt wird.
* @param fadeDurationMs Dauer des Fade-Effekts in Millisekunden.
* @param duckVolume Lautstärke (0.01.0) während des Duck-Modus.
* @param shuffle Playlist zufällig abspielen.
* @param autoStartAfterWaypoint Nach Wegpunkt-Audio Begleitmusik automatisch starten,
* auch wenn sie vorher nicht spielte.
*/
data class TourAudioSettings(
val enabled: Boolean = false,
val sourceType: MusicSourceType = MusicSourceType.LOCAL_PLAYLIST,
val localPlaylist: List<PlaylistItem> = emptyList(),
val streamUrl: String? = null,
val behavior: WaypointMusicBehavior = WaypointMusicBehavior.PAUSE_RESUME,
val fadeDurationMs: Long = 1500L,
val duckVolume: Float = 0.25f,
val shuffle: Boolean = false,
val autoStartAfterWaypoint: Boolean = false
)
@@ -0,0 +1,122 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
// Separater DataStore für Begleitmusik-Einstellungen
private val Context.musicDataStore: DataStore<Preferences> by preferencesDataStore(name = "tour_music")
/**
* Persistiert Begleitmusik-Einstellungen pro Tour (Key: Tourname).
* Serialisiert als JSON Map<String, TourAudioSettings>.
*/
class TourMusicStore(private val context: Context) {
private val gson: Gson = GsonBuilder().create()
private val mapType = object : TypeToken<Map<String, TourAudioSettingsJson>>() {}.type
companion object {
private val KEY_MUSIC_SETTINGS = stringPreferencesKey("music_settings_json")
}
/** Flow der Begleitmusik-Einstellungen für alle Touren. */
val settingsFlow: Flow<Map<String, TourAudioSettings>> =
context.musicDataStore.data.map { prefs ->
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
runCatching {
val raw: Map<String, TourAudioSettingsJson>? = gson.fromJson(json, mapType)
raw?.mapValues { (_, v) -> v.toDomain() } ?: emptyMap()
}.getOrDefault(emptyMap())
}
/** Liefert die Einstellungen für eine spezifische Tour. */
fun settingsForTour(tourName: String): Flow<TourAudioSettings> =
settingsFlow.map { map -> map[tourName] ?: TourAudioSettings() }
/** Speichert oder aktualisiert die Einstellungen für eine Tour. */
suspend fun save(tourName: String, settings: TourAudioSettings) {
context.musicDataStore.edit { prefs ->
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
val map: MutableMap<String, TourAudioSettingsJson> = runCatching {
gson.fromJson<Map<String, TourAudioSettingsJson>>(json, mapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map[tourName] = TourAudioSettingsJson.fromDomain(settings)
prefs[KEY_MUSIC_SETTINGS] = gson.toJson(map)
}
}
/** Löscht die Einstellungen für eine Tour (z. B. beim Tour-Löschen). */
suspend fun delete(tourName: String) {
context.musicDataStore.edit { prefs ->
val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}"
val map: MutableMap<String, TourAudioSettingsJson> = runCatching {
gson.fromJson<Map<String, TourAudioSettingsJson>>(json, mapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.remove(tourName)
prefs[KEY_MUSIC_SETTINGS] = gson.toJson(map)
}
}
}
// ---------------------------------------------------------------------------
// JSON-Repräsentation (Gson-kompatibel, ohne Kotlin-Enums direkt serialisieren)
// ---------------------------------------------------------------------------
internal data class PlaylistItemJson(
val uriString: String = "",
val displayName: String = ""
) {
fun toDomain() = PlaylistItem(uriString, displayName)
companion object {
fun fromDomain(item: PlaylistItem) = PlaylistItemJson(item.uriString, item.displayName)
}
}
internal data class TourAudioSettingsJson(
val enabled: Boolean = false,
val sourceType: String = "LOCAL_PLAYLIST",
val localPlaylist: List<PlaylistItemJson> = emptyList(),
val streamUrl: String? = null,
val behavior: String = "PAUSE_RESUME",
val fadeDurationMs: Long = 1500L,
val duckVolume: Float = 0.25f,
val shuffle: Boolean = false,
val autoStartAfterWaypoint: Boolean = false
) {
fun toDomain() = TourAudioSettings(
enabled = enabled,
sourceType = runCatching { MusicSourceType.valueOf(sourceType) }
.getOrDefault(MusicSourceType.LOCAL_PLAYLIST),
localPlaylist = localPlaylist.map { it.toDomain() },
streamUrl = streamUrl,
behavior = runCatching { WaypointMusicBehavior.valueOf(behavior) }
.getOrDefault(WaypointMusicBehavior.PAUSE_RESUME),
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
shuffle = shuffle,
autoStartAfterWaypoint = autoStartAfterWaypoint
)
companion object {
fun fromDomain(s: TourAudioSettings) = TourAudioSettingsJson(
enabled = s.enabled,
sourceType = s.sourceType.name,
localPlaylist = s.localPlaylist.map { PlaylistItemJson.fromDomain(it) },
streamUrl = s.streamUrl,
behavior = s.behavior.name,
fadeDurationMs = s.fadeDurationMs,
duckVolume = s.duckVolume,
shuffle = s.shuffle,
autoStartAfterWaypoint = s.autoStartAfterWaypoint
)
}
}
@@ -0,0 +1,15 @@
package de.waypointaudio.data
/**
* Tour-weite Abspiel-Vorgaben, die bei neuen Wegpunkten als Standardwerte verwendet werden
* und per Massen-Aktion auf alle vorhandenen Wegpunkte einer Tour angewendet werden können.
*
* @param tourName Name der Tour
* @param playbackMode Standard-Abspiel-Modus für neue und alle vorhandenen Wegpunkte
* @param maxPlayCount Standard-Maximale Abspielanzahl (nur für LIMITED_COUNT)
*/
data class TourPlaybackDefaults(
val tourName: String = Waypoint.DEFAULT_TOUR_NAME,
val playbackMode: PlaybackMode = PlaybackMode.EVERY_ENTRY,
val maxPlayCount: Int = 3
)
@@ -0,0 +1,70 @@
package de.waypointaudio.data
import java.util.UUID
/**
* Abspiel-Modus für einen Wegpunkt.
*
* EVERY_ENTRY Standardverhalten: Ton bei jedem erneuten Betreten.
* ONCE Ton nur beim ersten Betreten (playCount == 0).
* LIMITED_COUNT Ton maximal [maxPlayCount] Mal insgesamt.
*/
enum class PlaybackMode {
EVERY_ENTRY,
ONCE,
LIMITED_COUNT
}
/**
* Datenmodell für einen Wegpunkt.
*
* @param id Eindeutige ID (UUID)
* @param name Anzeigename des Wegpunkts
* @param latitude Breitengrad in Dezimalgrad
* @param longitude Längengrad in Dezimalgrad
* @param radiusMeters Erkennungsradius in Metern
* @param soundUri URI zur Audiodatei (content:// oder file://)
* @param soundName Anzeigename der Audiodatei
* @param isActive Ob der Wegpunkt aktiv überwacht wird
* @param tourName Name der Tour/Route (Standard: "Standard"). Rückwärtskompatibel:
* bestehende Wegpunkte ohne dieses Feld erhalten automatisch "Standard".
*
* --- Abspielregeln (optional, Standardwerte = bisheriges Verhalten) ---
*
* @param playbackMode Abspiel-Modus (Standard: EVERY_ENTRY = bisheriges Verhalten)
* @param maxPlayCount Maximale Abspielanzahl für LIMITED_COUNT; null = kein Limit
* @param playCount Bisherige Abspielanzahl (persistent, wird vom Service inkrementiert)
* @param scheduleEnabled Ob der Zeitplan aktiv ist
* @param scheduleStartMillis Frühestes Datum/Uhrzeit (Unix-Millisekunden), ab dem Abspielen erlaubt ist
* @param scheduleEndMillis Spätestes Datum/Uhrzeit (Unix-Millisekunden), bis zu dem Abspielen erlaubt ist
* @param allowedStartMinutes Tagesminute (01439), ab der das tägliche Zeitfenster beginnt
* @param allowedEndMinutes Tagesminute (01439), bis zu der das tägliche Zeitfenster endet
*/
data class Waypoint(
val id: String = UUID.randomUUID().toString(),
val name: String = "",
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val radiusMeters: Float = 50f,
val soundUri: String = "",
val soundName: String = "",
val isActive: Boolean = true,
// Tour-Zuordnung safe default "Standard" für Rückwärtskompatibilität
val tourName: String = DEFAULT_TOUR_NAME,
// Abspielregeln alle optional, Standardwerte entsprechen bisherigem Verhalten
val playbackMode: PlaybackMode = PlaybackMode.EVERY_ENTRY,
val maxPlayCount: Int? = null,
val playCount: Int = 0,
val scheduleEnabled: Boolean = false,
val scheduleStartMillis: Long? = null,
val scheduleEndMillis: Long? = null,
val allowedStartMinutes: Int? = null,
val allowedEndMinutes: Int? = null
) {
companion object {
/** Standard-Tourname für bestehende Wegpunkte ohne tourName-Feld. */
const val DEFAULT_TOUR_NAME = "Standard"
}
}
@@ -0,0 +1,254 @@
package de.waypointaudio.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
// DataStore-Instanz als Extension Property (Singleton pro Context)
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "waypoints")
/**
* Lokaler persistenter Speicher für Wegpunkte und Touren.
* Nutzt Jetpack DataStore mit JSON-Serialisierung via Gson.
*
* tourList speichert die geordnete Liste aller Tournamen (inkl. Standardtour).
* Touren werden zusätzlich aus den Wegpunkt-tourName-Feldern abgeleitet,
* damit auch leere Touren (ohne Wegpunkte) persistiert werden.
*
* tourPlaybackDefaults speichert die tour-weiten Abspiel-Vorgaben als JSON-Map.
*/
class WaypointStore(private val context: Context) {
private val gson: Gson = GsonBuilder().create()
private val waypointListType = object : TypeToken<List<Waypoint>>() {}.type
private val stringListType = object : TypeToken<List<String>>() {}.type
private val defaultsMapType = object : TypeToken<Map<String, TourPlaybackDefaults>>() {}.type
companion object {
private val KEY_WAYPOINTS = stringPreferencesKey("waypoints_json")
private val KEY_TOURS = stringPreferencesKey("tours_json")
private val KEY_TOUR_DEFAULTS = stringPreferencesKey("tour_playback_defaults_json")
private val KEY_GPS_TRACKS = stringPreferencesKey("gps_tracks_json")
}
/** Liefert einen Flow mit der aktuellen Wegpunktliste. */
val waypointsFlow: Flow<List<Waypoint>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType) ?: emptyList()
}.getOrDefault(emptyList())
}
/**
* Liefert einen Flow mit der persistierten Tourliste.
* Die Liste enthält mindestens die Standardtour.
* Touren aus Wegpunkten werden ggf. ergänzt, aber die Reihenfolge
* (und leere Touren) stammen aus diesem Key.
*/
val toursFlow: Flow<List<String>> = context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOURS] ?: "[]"
val stored = runCatching {
gson.fromJson<List<String>>(json, stringListType) ?: emptyList()
}.getOrDefault(emptyList())
// Immer mindestens die Standardtour enthalten
if (stored.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else stored
}
/** Liefert einen Flow mit der Map tour → TourPlaybackDefaults. */
val tourDefaultsFlow: Flow<Map<String, TourPlaybackDefaults>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}"
runCatching {
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/** GPS-Track-Flow: Map tour → Liste von TrackPoint. */
val gpsTracksFlow: Flow<Map<String, List<GpsTrackPoint>>> =
context.dataStore.data.map { prefs ->
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
runCatching {
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType) ?: emptyMap()
}.getOrDefault(emptyMap())
}
/** Speichert die gesamte Wegpunktliste. */
suspend fun saveAll(waypoints: List<Waypoint>) {
context.dataStore.edit { prefs ->
prefs[KEY_WAYPOINTS] = gson.toJson(waypoints)
}
}
/** Speichert die gesamte Tourliste (Reihenfolge bleibt erhalten). */
suspend fun saveTours(tours: List<String>) {
context.dataStore.edit { prefs ->
prefs[KEY_TOURS] = gson.toJson(tours)
}
}
/** Speichert oder aktualisiert die Abspiel-Vorgaben einer Tour. */
suspend fun saveTourDefaults(tourName: String, defaults: TourPlaybackDefaults) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}"
val map: MutableMap<String, TourPlaybackDefaults> = runCatching {
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType)
?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map[tourName] = defaults
prefs[KEY_TOUR_DEFAULTS] = gson.toJson(map)
}
}
/** Liest die Abspiel-Vorgaben einer Tour einmalig (ohne Flow). */
suspend fun getTourDefaults(tourName: String): TourPlaybackDefaults {
val json = context.dataStore.data.first()[KEY_TOUR_DEFAULTS] ?: "{}"
val map: Map<String, TourPlaybackDefaults> = runCatching {
gson.fromJson<Map<String, TourPlaybackDefaults>>(json, defaultsMapType) ?: emptyMap()
}.getOrDefault(emptyMap())
return map[tourName] ?: TourPlaybackDefaults(tourName = tourName)
}
/** Speichert den GPS-Track einer Tour. */
suspend fun saveGpsTrack(tourName: String, points: List<GpsTrackPoint>) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
val map: MutableMap<String, List<GpsTrackPoint>> = runCatching {
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map[tourName] = points
prefs[KEY_GPS_TRACKS] = gson.toJson(map)
}
}
/** Löscht den GPS-Track einer Tour. */
suspend fun clearGpsTrack(tourName: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_GPS_TRACKS] ?: "{}"
val mapType = object : TypeToken<Map<String, List<GpsTrackPoint>>>() {}.type
val map: MutableMap<String, List<GpsTrackPoint>> = runCatching {
gson.fromJson<Map<String, List<GpsTrackPoint>>>(json, mapType)?.toMutableMap()
}.getOrNull() ?: mutableMapOf()
map.remove(tourName)
prefs[KEY_GPS_TRACKS] = gson.toJson(map)
}
}
/** Fügt einen Wegpunkt hinzu oder aktualisiert einen vorhandenen (matching id). */
suspend fun upsert(waypoint: Waypoint) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
val list: MutableList<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
val index = list.indexOfFirst { it.id == waypoint.id }
if (index >= 0) list[index] = waypoint else list.add(waypoint)
prefs[KEY_WAYPOINTS] = gson.toJson(list)
}
}
/** Löscht einen Wegpunkt anhand seiner ID. */
suspend fun delete(id: String) {
context.dataStore.edit { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
val list: MutableList<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
list.removeAll { it.id == id }
prefs[KEY_WAYPOINTS] = gson.toJson(list)
}
}
/**
* Inkrementiert den playCount eines Wegpunkts atomar.
* Wird vom WaypointLocationService nach erfolgreicher Wiedergabe aufgerufen.
* @return den neuen Wegpunkt mit inkrementiertem playCount, oder null wenn nicht gefunden.
*/
suspend fun incrementPlayCount(waypointId: String): Waypoint? {
var updated: Waypoint? = null
context.dataStore.edit { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
val list: MutableList<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
val index = list.indexOfFirst { it.id == waypointId }
if (index >= 0) {
val wp = list[index].copy(playCount = list[index].playCount + 1)
list[index] = wp
updated = wp
}
prefs[KEY_WAYPOINTS] = gson.toJson(list)
}
return updated
}
/**
* Setzt den playCount aller Wegpunkte einer Tour auf 0 zurück.
* @return Anzahl der zurückgesetzten Wegpunkte.
*/
suspend fun resetPlayCountsForTour(tourName: String): Int {
var count = 0
context.dataStore.edit { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
val list: MutableList<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
for (i in list.indices) {
val wp = list[i]
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName && wp.playCount != 0) {
list[i] = wp.copy(playCount = 0)
count++
}
}
prefs[KEY_WAYPOINTS] = gson.toJson(list)
}
return count
}
/**
* Wendet einen Abspiel-Modus auf alle Wegpunkte einer Tour an.
* Optional: setzt auch maxPlayCount, optional: setzt playCount zurück.
* @return Anzahl der aktualisierten Wegpunkte.
*/
suspend fun applyPlaybackModeForTour(
tourName: String,
mode: PlaybackMode,
maxPlayCount: Int?,
resetCounts: Boolean
): Int {
var count = 0
context.dataStore.edit { prefs ->
val json = prefs[KEY_WAYPOINTS] ?: "[]"
val list: MutableList<Waypoint> = runCatching {
gson.fromJson<List<Waypoint>>(json, waypointListType)?.toMutableList()
}.getOrNull() ?: mutableListOf()
for (i in list.indices) {
val wp = list[i]
if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tourName) {
list[i] = wp.copy(
playbackMode = mode,
maxPlayCount = if (mode == PlaybackMode.LIMITED_COUNT) maxPlayCount else null,
playCount = if (resetCounts) 0 else wp.playCount
)
count++
}
}
prefs[KEY_WAYPOINTS] = gson.toJson(list)
}
return count
}
}
@@ -0,0 +1,407 @@
package de.waypointaudio.io
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.Waypoint
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import org.xmlpull.v1.XmlSerializer
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
/**
* Verwaltet den Import und Export von Wegpunkten als JSON und GPX 1.1.
*
* JSON-Format: Array von Waypoint-Objekten (Gson-serialisiert, alle Felder inkl. tourName).
* Rückwärtskompatibel: Ältere JSON-Dateien ohne tourName erhalten automatisch
* den Standardwert [Waypoint.DEFAULT_TOUR_NAME] via Kotlin-Standardparameter.
*
* GPX-Format: GPX 1.1 mit <wpt>-Elementen.
* - lat, lon als Attribute
* - <name> aus waypoint.name
* - <desc> enthält Radius und Sound-Name (wenn vorhanden)
* - <extensions>: <wpa:radius>, <wpa:soundName>, <wpa:tourName> und alle Abspielregel-Felder
*/
object ImportExportManager {
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
private val waypointListType = object : TypeToken<List<Waypoint>>() {}.type
// -------------------------------------------------------------------------
// JSON Export
// -------------------------------------------------------------------------
/**
* Serialisiert die Wegpunktliste als JSON-String.
* tourName wird automatisch durch Gson serialisiert (Kotlin data class).
*/
fun toJson(waypoints: List<Waypoint>): String = gson.toJson(waypoints)
/**
* Schreibt JSON in einen URI (via ContentResolver).
* @return Fehlermeldung oder null bei Erfolg.
*/
fun exportJson(context: Context, uri: Uri, waypoints: List<Waypoint>): String? {
return runCatching {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.writer().use { it.write(toJson(waypoints)) }
} ?: return "Konnte Datei nicht öffnen: $uri"
}.exceptionOrNull()?.message
}
// -------------------------------------------------------------------------
// JSON Import
// -------------------------------------------------------------------------
/**
* Liest Wegpunkte aus einem JSON-URI.
* Fehlende neue Felder (inkl. tourName) erhalten durch Kotlin-Standardwerte ihre sicheren Defaults.
* @return Pair(liste, fehler) bei Fehler ist die Liste leer.
*/
fun importJson(context: Context, uri: Uri): Pair<List<Waypoint>, String?> {
return runCatching<Pair<List<Waypoint>, String?>> {
val json = context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
?: return Pair(emptyList(), "Datei konnte nicht gelesen werden")
val list: List<Waypoint>? = gson.fromJson(json, waypointListType)
if (list == null || list.isEmpty()) {
return Pair(emptyList(), "JSON enthält keine Wegpunkte")
}
// Sicherstellen, dass alle IDs eindeutig und tourName nicht leer sind
val sanitized = list.map { wp ->
wp.copy(
id = if (wp.id.isBlank()) UUID.randomUUID().toString() else wp.id,
tourName = wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
)
}
Pair(sanitized, null)
}.getOrElse { e ->
Pair(emptyList(), "JSON-Fehler: ${e.localizedMessage}")
}
}
// -------------------------------------------------------------------------
// GPX Export
// -------------------------------------------------------------------------
/**
* Erstellt einen GPX 1.1 XML-String für die gegebene Wegpunktliste.
* Enthält alle Abspielregel-Felder und tourName als <wpa:...> Erweiterungen.
*/
fun toGpx(waypoints: List<Waypoint>): String {
val sw = StringWriter()
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val xs: XmlSerializer = factory.newSerializer()
xs.setOutput(sw)
xs.startDocument("UTF-8", true)
xs.text("\n")
xs.startTag("", "gpx")
xs.attribute("", "version", "1.1")
xs.attribute("", "creator", "GPS2Audio")
xs.attribute("", "xmlns", "http://www.topografix.com/GPX/1/1")
xs.attribute("", "xmlns:wpa", "https://github.com/waypoint-audio-guide")
xs.attribute("", "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
xs.attribute(
"", "xsi:schemaLocation",
"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd"
)
xs.text("\n")
// metadata
xs.startTag("", "metadata")
xs.text("\n ")
xs.startTag("", "name")
xs.text("GPS2Audio Export")
xs.endTag("", "name")
xs.text("\n ")
xs.startTag("", "time")
val isoDate = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).format(Date())
xs.text(isoDate)
xs.endTag("", "time")
xs.text("\n")
xs.endTag("", "metadata")
xs.text("\n")
for (wp in waypoints) {
xs.startTag("", "wpt")
xs.attribute("", "lat", wp.latitude.toString())
xs.attribute("", "lon", wp.longitude.toString())
xs.text("\n ")
xs.startTag("", "name")
xs.text(wp.name)
xs.endTag("", "name")
xs.text("\n ")
// desc: human-readable summary
val descParts = mutableListOf("Radius: ${wp.radiusMeters.toInt()}m")
if (wp.soundName.isNotBlank()) descParts.add("Sound: ${wp.soundName}")
val modeDesc = when (wp.playbackMode) {
PlaybackMode.EVERY_ENTRY -> "Abspielen: bei jedem Betreten"
PlaybackMode.ONCE -> "Abspielen: nur einmal"
PlaybackMode.LIMITED_COUNT -> "Abspielen: max. ${wp.maxPlayCount ?: 1}×"
}
descParts.add(modeDesc)
descParts.add("Tour: ${wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }}")
xs.startTag("", "desc")
xs.text(descParts.joinToString(", "))
xs.endTag("", "desc")
xs.text("\n ")
// extensions: machine-readable fields for round-trip
xs.startTag("", "extensions")
xs.text("\n ")
xs.startTag("wpa", "id")
xs.text(wp.id)
xs.endTag("wpa", "id")
xs.text("\n ")
xs.startTag("wpa", "radius")
xs.text(wp.radiusMeters.toString())
xs.endTag("wpa", "radius")
xs.text("\n ")
xs.startTag("wpa", "isActive")
xs.text(wp.isActive.toString())
xs.endTag("wpa", "isActive")
// tourName
xs.text("\n ")
xs.startTag("wpa", "tourName")
xs.text(wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME })
xs.endTag("wpa", "tourName")
if (wp.soundName.isNotBlank()) {
xs.text("\n ")
xs.startTag("wpa", "soundName")
xs.text(wp.soundName)
xs.endTag("wpa", "soundName")
}
// soundUri is intentionally NOT exported (content:// URIs are device-local)
// --- Abspielregel-Felder ---
xs.text("\n ")
xs.startTag("wpa", "playbackMode")
xs.text(wp.playbackMode.name)
xs.endTag("wpa", "playbackMode")
xs.text("\n ")
xs.startTag("wpa", "playCount")
xs.text(wp.playCount.toString())
xs.endTag("wpa", "playCount")
wp.maxPlayCount?.let { max ->
xs.text("\n ")
xs.startTag("wpa", "maxPlayCount")
xs.text(max.toString())
xs.endTag("wpa", "maxPlayCount")
}
xs.text("\n ")
xs.startTag("wpa", "scheduleEnabled")
xs.text(wp.scheduleEnabled.toString())
xs.endTag("wpa", "scheduleEnabled")
wp.scheduleStartMillis?.let { start ->
xs.text("\n ")
xs.startTag("wpa", "scheduleStartMillis")
xs.text(start.toString())
xs.endTag("wpa", "scheduleStartMillis")
}
wp.scheduleEndMillis?.let { end ->
xs.text("\n ")
xs.startTag("wpa", "scheduleEndMillis")
xs.text(end.toString())
xs.endTag("wpa", "scheduleEndMillis")
}
wp.allowedStartMinutes?.let { startMin ->
xs.text("\n ")
xs.startTag("wpa", "allowedStartMinutes")
xs.text(startMin.toString())
xs.endTag("wpa", "allowedStartMinutes")
}
wp.allowedEndMinutes?.let { endMin ->
xs.text("\n ")
xs.startTag("wpa", "allowedEndMinutes")
xs.text(endMin.toString())
xs.endTag("wpa", "allowedEndMinutes")
}
xs.text("\n ")
xs.endTag("", "extensions")
xs.text("\n")
xs.endTag("", "wpt")
xs.text("\n")
}
xs.endTag("", "gpx")
xs.endDocument()
return sw.toString()
}
/**
* Schreibt GPX in einen URI (via ContentResolver).
* @return Fehlermeldung oder null bei Erfolg.
*/
fun exportGpx(context: Context, uri: Uri, waypoints: List<Waypoint>): String? {
return runCatching {
context.contentResolver.openOutputStream(uri)?.use { out ->
out.writer().use { it.write(toGpx(waypoints)) }
} ?: return "Konnte Datei nicht öffnen: $uri"
}.exceptionOrNull()?.message
}
// -------------------------------------------------------------------------
// GPX Import
// -------------------------------------------------------------------------
/**
* Liest Wegpunkte aus einer GPX 1.1 Datei.
* Felder ohne wpa:extensions erhalten Standardwerte (Radius 50m, kein Sound).
* tourName ist optional; fehlt es, gilt [Waypoint.DEFAULT_TOUR_NAME].
* @return Pair(liste, fehler)
*/
fun importGpx(context: Context, uri: Uri): Pair<List<Waypoint>, String?> {
return runCatching<Pair<List<Waypoint>, String?>> {
val inputStream = context.contentResolver.openInputStream(uri)
?: return Pair(emptyList<Waypoint>(), "GPX-Datei konnte nicht geöffnet werden")
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser: XmlPullParser = factory.newPullParser()
parser.setInput(inputStream, null)
val waypoints = mutableListOf<Waypoint>()
// Current wpt fields
var inWpt = false
var inExtensions = false
var lat = Double.NaN
var lon = Double.NaN
var name = ""
var id = ""
var radius = 50f
var isActive = true
var soundName = ""
var tourName = ""
// Abspielregel-Felder
var playbackModeStr = ""
var playCount = 0
var maxPlayCount: Int? = null
var scheduleEnabled = false
var scheduleStartMillis: Long? = null
var scheduleEndMillis: Long? = null
var allowedStartMinutes: Int? = null
var allowedEndMinutes: Int? = null
var currentTag = ""
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> {
val tag = parser.name ?: ""
currentTag = tag
when {
tag == "wpt" -> {
inWpt = true
inExtensions = false
lat = parser.getAttributeValue(null, "lat")?.toDoubleOrNull() ?: Double.NaN
lon = parser.getAttributeValue(null, "lon")?.toDoubleOrNull() ?: Double.NaN
// Reset all fields
name = ""; id = ""; radius = 50f; isActive = true; soundName = ""
tourName = ""
playbackModeStr = ""; playCount = 0; maxPlayCount = null
scheduleEnabled = false
scheduleStartMillis = null; scheduleEndMillis = null
allowedStartMinutes = null; allowedEndMinutes = null
}
tag == "extensions" && inWpt -> inExtensions = true
}
}
XmlPullParser.TEXT -> {
val text = parser.text?.trim() ?: ""
if (inWpt && text.isNotEmpty()) {
when {
currentTag == "name" && !inExtensions -> name = text
currentTag == "id" && inExtensions -> id = text
currentTag == "radius" && inExtensions -> radius = text.toFloatOrNull() ?: 50f
currentTag == "isActive" && inExtensions -> isActive = text.toBooleanStrictOrNull() ?: true
currentTag == "soundName" && inExtensions -> soundName = text
currentTag == "tourName" && inExtensions -> tourName = text
// Abspielregel-Felder
currentTag == "playbackMode" && inExtensions -> playbackModeStr = text
currentTag == "playCount" && inExtensions -> playCount = text.toIntOrNull() ?: 0
currentTag == "maxPlayCount" && inExtensions -> maxPlayCount = text.toIntOrNull()
currentTag == "scheduleEnabled" && inExtensions -> scheduleEnabled = text.toBooleanStrictOrNull() ?: false
currentTag == "scheduleStartMillis" && inExtensions -> scheduleStartMillis = text.toLongOrNull()
currentTag == "scheduleEndMillis" && inExtensions -> scheduleEndMillis = text.toLongOrNull()
currentTag == "allowedStartMinutes" && inExtensions -> allowedStartMinutes = text.toIntOrNull()
currentTag == "allowedEndMinutes" && inExtensions -> allowedEndMinutes = text.toIntOrNull()
}
}
}
XmlPullParser.END_TAG -> {
val tag = parser.name ?: ""
when {
tag == "extensions" -> inExtensions = false
tag == "wpt" -> {
if (!lat.isNaN() && !lon.isNaN()) {
val mode = runCatching {
if (playbackModeStr.isBlank()) PlaybackMode.EVERY_ENTRY
else PlaybackMode.valueOf(playbackModeStr)
}.getOrDefault(PlaybackMode.EVERY_ENTRY)
waypoints.add(
Waypoint(
id = id.ifBlank { UUID.randomUUID().toString() },
name = name.ifBlank { "Wegpunkt ${waypoints.size + 1}" },
latitude = lat,
longitude = lon,
radiusMeters = radius.coerceAtLeast(1f),
soundUri = "", // content:// URIs cannot be transferred
soundName = soundName,
isActive = isActive,
tourName = tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME },
playbackMode = mode,
playCount = playCount.coerceAtLeast(0),
maxPlayCount = maxPlayCount,
scheduleEnabled = scheduleEnabled,
scheduleStartMillis = scheduleStartMillis,
scheduleEndMillis = scheduleEndMillis,
allowedStartMinutes = allowedStartMinutes,
allowedEndMinutes = allowedEndMinutes
)
)
}
inWpt = false
currentTag = ""
}
}
if (!inExtensions && !inWpt) currentTag = ""
}
}
event = parser.next()
}
if (waypoints.isEmpty()) {
Pair(emptyList<Waypoint>(), "GPX enthält keine gültigen Wegpunkte (<wpt> mit lat/lon)")
} else {
Pair(waypoints, null)
}
}.getOrElse { e ->
Pair(emptyList<Waypoint>(), "GPX-Fehler: ${e.localizedMessage}")
}
}
}
@@ -0,0 +1,76 @@
package de.waypointaudio.repository
import android.content.Context
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.Waypoint
import de.waypointaudio.data.WaypointStore
import kotlinx.coroutines.flow.Flow
/**
* Repository-Schicht für Wegpunkte und Touren.
* Abstrahiert den DataStore-Zugriff von UI und Service.
*/
class WaypointRepository(context: Context) {
private val store = WaypointStore(context)
/** Reaktiver Flow der Wegpunktliste. */
val waypoints: Flow<List<Waypoint>> = store.waypointsFlow
/** Reaktiver Flow der persistierten Tourliste. */
val tours: Flow<List<String>> = store.toursFlow
/** Reaktiver Flow der Tour-weiten Abspiel-Vorgaben. */
val tourDefaults: Flow<Map<String, TourPlaybackDefaults>> = store.tourDefaultsFlow
/** Reaktiver Flow der gespeicherten GPS-Tracks. */
val gpsTracks: Flow<Map<String, List<GpsTrackPoint>>> = store.gpsTracksFlow
/** Wegpunkt hinzufügen oder aktualisieren. */
suspend fun upsert(waypoint: Waypoint) = store.upsert(waypoint)
/** Wegpunkt löschen. */
suspend fun delete(id: String) = store.delete(id)
/** Alle Wegpunkte ersetzen. */
suspend fun saveAll(waypoints: List<Waypoint>) = store.saveAll(waypoints)
/** Tourliste persistieren. */
suspend fun saveTours(tours: List<String>) = store.saveTours(tours)
/**
* Inkrementiert den playCount eines Wegpunkts atomar nach erfolgreicher Wiedergabe.
* @return den aktualisierten Wegpunkt oder null wenn nicht gefunden.
*/
suspend fun incrementPlayCount(waypointId: String): Waypoint? =
store.incrementPlayCount(waypointId)
/** Setzt den playCount aller Wegpunkte einer Tour auf 0 zurück. */
suspend fun resetPlayCountsForTour(tourName: String): Int =
store.resetPlayCountsForTour(tourName)
/** Wendet einen Abspiel-Modus auf alle Wegpunkte einer Tour an. */
suspend fun applyPlaybackModeForTour(
tourName: String,
mode: de.waypointaudio.data.PlaybackMode,
maxPlayCount: Int?,
resetCounts: Boolean
): Int = store.applyPlaybackModeForTour(tourName, mode, maxPlayCount, resetCounts)
/** Speichert die Tour-weiten Abspiel-Vorgaben. */
suspend fun saveTourDefaults(tourName: String, defaults: TourPlaybackDefaults) =
store.saveTourDefaults(tourName, defaults)
/** Liest die Tour-weiten Abspiel-Vorgaben einmalig. */
suspend fun getTourDefaults(tourName: String): TourPlaybackDefaults =
store.getTourDefaults(tourName)
/** Speichert den GPS-Track einer Tour (persistiert). */
suspend fun saveGpsTrack(tourName: String, points: List<GpsTrackPoint>) =
store.saveGpsTrack(tourName, points)
/** Löscht den GPS-Track einer Tour. */
suspend fun clearGpsTrack(tourName: String) =
store.clearGpsTrack(tourName)
}
@@ -0,0 +1,145 @@
package de.waypointaudio.service
import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.util.Log
/**
* Vereinfachtes Modell für ein Audio-Gerät, das in der UI angezeigt werden kann.
*
* @param id AudioDeviceInfo.id (Sitzungs-spezifisch, kann sich nach Neustart ändern)
* @param displayName Lesbarer Name auf Deutsch
* @param isInput true = Eingabegerät (Mikrofon), false = Ausgabegerät
* @param rawType AudioDeviceInfo.type, für interne Logik
*/
data class AudioDeviceItem(
val id: Int,
val displayName: String,
val isInput: Boolean,
val rawType: Int
)
/**
* Hilfsklasse zum Auflisten verfügbarer Audio-Eingabe- und Ausgabegeräte.
*
* Hinweis: Die zurückgegebenen Geräte-IDs (AudioDeviceInfo.id) sind sitzungsspezifisch.
* Wenn ein gespeichertes Gerät nicht mehr in der Liste erscheint, wird der Systemstandard
* verwendet.
*/
object AudioDeviceManager {
private const val TAG = "AudioDeviceManager"
/**
* Gibt alle verfügbaren Eingabegeräte zurück (Mikrofone, Bluetooth-Headsets usw.).
* Die Liste enthält keine System-internen Geräte (REMOTE_SUBMIX etc.).
*/
fun getInputDevices(context: Context): List<AudioDeviceItem> {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return am.getDevices(AudioManager.GET_DEVICES_INPUTS)
.filter { isUsefulInputDevice(it.type) }
.map { deviceToItem(it, isInput = true) }
.also { Log.d(TAG, "Eingabegeräte: ${it.size}") }
}
/**
* Gibt alle verfügbaren Ausgabegeräte zurück (Lautsprecher, Kopfhörer, Bluetooth usw.).
*/
fun getOutputDevices(context: Context): List<AudioDeviceItem> {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return am.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.filter { isUsefulOutputDevice(it.type) }
.map { deviceToItem(it, isInput = false) }
.also { Log.d(TAG, "Ausgabegeräte: ${it.size}") }
}
/**
* Sucht ein Eingabegerät anhand seiner ID. Gibt null zurück wenn nicht gefunden.
*/
fun findInputDevice(context: Context, deviceId: Int): AudioDeviceInfo? {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { it.id == deviceId }
}
/**
* Sucht ein Ausgabegerät anhand seiner ID. Gibt null zurück wenn nicht gefunden.
*/
fun findOutputDevice(context: Context, deviceId: Int): AudioDeviceInfo? {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { it.id == deviceId }
}
// ─── Private Hilfsfunktionen ──────────────────────────────────────────────
private fun deviceToItem(info: AudioDeviceInfo, isInput: Boolean): AudioDeviceItem {
val name = buildDisplayName(info)
return AudioDeviceItem(
id = info.id,
displayName = name,
isInput = isInput,
rawType = info.type
)
}
private fun buildDisplayName(info: AudioDeviceInfo): String {
val typeName = germanTypeName(info.type)
// If the device has a product name, append it for disambiguation
val productName = try {
info.productName?.toString()?.trim()
} catch (_: Exception) { null }
return if (!productName.isNullOrBlank() && productName != typeName) {
"$typeName $productName"
} else {
typeName
}
}
/**
* Liefert einen verständlichen deutschen Namen für den AudioDeviceInfo.type.
*/
fun germanTypeName(type: Int): String = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internes Mikrofon"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Hörmuschel"
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Lautsprecher"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Kabelgebundenes Headset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Kabelgebundene Kopfhörer"
AudioDeviceInfo.TYPE_LINE_ANALOG -> "Analoger Line-Ausgang"
AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digitaler Line-Ausgang"
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth (SCO/Telefon)"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth (A2DP/Stereo)"
AudioDeviceInfo.TYPE_HDMI -> "HDMI"
AudioDeviceInfo.TYPE_HDMI_ARC -> "HDMI ARC"
AudioDeviceInfo.TYPE_USB_DEVICE -> "USB-Gerät"
AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB-Zubehör"
AudioDeviceInfo.TYPE_USB_HEADSET -> "USB-Headset"
AudioDeviceInfo.TYPE_TELEPHONY -> "Telefon"
AudioDeviceInfo.TYPE_IP -> "IP-Audio"
AudioDeviceInfo.TYPE_BUS -> "Bus-Audio"
AudioDeviceInfo.TYPE_FM -> "FM"
AudioDeviceInfo.TYPE_FM_TUNER -> "FM-Tuner"
AudioDeviceInfo.TYPE_TV_TUNER -> "TV-Tuner"
AudioDeviceInfo.TYPE_DOCK -> "Dockingstation"
AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "Remote-Submix (intern)"
AudioDeviceInfo.TYPE_UNKNOWN -> "Unbekanntes Gerät"
else -> "Audiogerät (Typ $type)"
}
private fun isUsefulInputDevice(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
AudioDeviceInfo.TYPE_TELEPHONY,
AudioDeviceInfo.TYPE_FM_TUNER,
AudioDeviceInfo.TYPE_TV_TUNER -> false
else -> true
}
private fun isUsefulOutputDevice(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_REMOTE_SUBMIX,
AudioDeviceInfo.TYPE_TELEPHONY,
AudioDeviceInfo.TYPE_FM,
AudioDeviceInfo.TYPE_FM_TUNER,
AudioDeviceInfo.TYPE_TV_TUNER -> false
else -> true
}
}
@@ -0,0 +1,110 @@
package de.waypointaudio.service
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
import android.util.Log
/**
* Einfacher Audio-Player für Wegpunkt-Sounds (GPS-Dienst).
* Nutzt Android MediaPlayer; spielt Content-URIs oder File-URIs ab.
*
* Completion- und Error-Callbacks erlauben dem Aufrufer, exakt nach dem
* Ende der Wiedergabe (oder bei einem Fehler) auf das Ereignis zu reagieren
* z. B. um die Begleitmusik korrekt wiederherzustellen.
*
* Thread-Sicherheit: Die Callbacks werden auf dem MediaPlayer-internen Thread
* aufgerufen. Der Aufrufer ist dafür verantwortlich, ggf. in den richtigen
* Dispatcher zu wechseln (z. B. Dispatchers.Main für ExoPlayer-Zugriffe).
*/
class AudioPlayer {
private var mediaPlayer: MediaPlayer? = null
/**
* Spielt eine Audiodatei ab.
*
* @param context Android-Context
* @param soundUri URI-String (content:// oder file://)
* @param onCompletion Wird aufgerufen, wenn die Wiedergabe natürlich endet.
* Wird auf dem MediaPlayer-Thread aufgerufen.
* @param onError Wird aufgerufen, wenn die Wiedergabe nicht gestartet
* werden kann oder ein MediaPlayer-Fehler auftritt.
* Wird auf dem MediaPlayer-Thread (oder dem Aufruf-Thread
* bei Initialisierungsfehlern) aufgerufen.
*/
fun play(
context: Context,
soundUri: String,
onCompletion: (() -> Unit)? = null,
onError: ((Exception?) -> Unit)? = null
) {
if (soundUri.isBlank()) {
Log.w(TAG, "Keine Audiodatei angegeben.")
onError?.invoke(IllegalArgumentException("soundUri ist leer"))
return
}
stop() // vorherige Wiedergabe stoppen
// callbackFired verhindert Doppel-Callbacks: completion und error
// schließen sich gegenseitig aus.
var callbackFired = false
runCatching {
val uri = Uri.parse(soundUri)
val mp = MediaPlayer()
mediaPlayer = mp
mp.setDataSource(context, uri)
mp.prepare()
mp.setOnCompletionListener { finishedMp ->
if (!callbackFired) {
callbackFired = true
finishedMp.release()
if (mediaPlayer === finishedMp) mediaPlayer = null
Log.d(TAG, "Wiedergabe beendet: $soundUri")
onCompletion?.invoke()
}
}
mp.setOnErrorListener { errorMp, what, extra ->
if (!callbackFired) {
callbackFired = true
errorMp.release()
if (mediaPlayer === errorMp) mediaPlayer = null
val msg = "MediaPlayer-Fehler: what=$what extra=$extra"
Log.e(TAG, msg)
onError?.invoke(RuntimeException(msg))
}
true // Fehler wurde behandelt → kein zusätzlicher onCompletion-Aufruf
}
mp.start()
}.onFailure { e ->
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
// Ressource freigeben falls teilweise initialisiert
runCatching { mediaPlayer?.release() }
mediaPlayer = null
if (!callbackFired) {
callbackFired = true
onError?.invoke(e as? Exception ?: RuntimeException(e))
}
}
}
/** Stoppt die aktuelle Wiedergabe und gibt Ressourcen frei. */
fun stop() {
runCatching {
mediaPlayer?.let {
if (it.isPlaying) it.stop()
it.release()
}
}
mediaPlayer = null
}
companion object {
private const val TAG = "AudioPlayer"
}
}
@@ -0,0 +1,187 @@
package de.waypointaudio.service
import android.content.Context
import android.util.Log
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourMusicStore
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* App-weiter Singleton-Manager für die Begleitmusik.
*
* Stellt einen zentralen [BackgroundMusicPlayer] bereit, der sowohl vom ViewModel
* (manuelle Wiedergabe) als auch vom [WaypointLocationService] (GPS-Trigger) genutzt wird.
*
* Der Manager überbrückt den App-Prozess Service und ViewModel teilen sich dieselbe Instanz.
*
* Lifecycle: Wird in [de.waypointaudio.WaypointApp] initialisiert und läuft für die gesamte
* Prozess-Lifetime. Release erfolgt in [release].
*/
class BackgroundMusicManager(private val appContext: Context) {
companion object {
private const val TAG = "BackgroundMusicManager"
@Volatile
private var instance: BackgroundMusicManager? = null
fun getInstance(context: Context): BackgroundMusicManager {
return instance ?: synchronized(this) {
instance ?: BackgroundMusicManager(context.applicationContext).also { instance = it }
}
}
}
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e(TAG, "Unbehandelte Ausnahme im BackgroundMusicManager", throwable)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
private val store = TourMusicStore(appContext)
/** Der verwaltete Player. Läuft auf dem Main-Thread. */
val player = BackgroundMusicPlayer(appContext)
/** Aktuell aktive Tour-Einstellungen (gecacht nach letztem [loadTour]). */
var currentSettings: TourAudioSettings = TourAudioSettings()
/** Aktueller Tourname. */
var currentTourName: String = ""
private set
// -----------------------------------------------------------------------
// Öffentliche API
// -----------------------------------------------------------------------
/**
* Lädt die Einstellungen für eine Tour und bereitet den Player vor.
* Stoppt ggf. laufende Musik der vorherigen Tour.
* Lädt immer neu aus dem Store (auch wenn dieselbe Tour nochmals geladen wird),
* damit nach Konfigurationsänderungen die aktuellen Einstellungen gelten.
*
* @param tourName Name der Tour
* @param force true = Einstellungen auch bei gleicher Tour neu laden (z. B. nach Speichern)
*/
fun loadTour(tourName: String, force: Boolean = false) {
if (currentTourName == tourName && !force) return
val previousTour = currentTourName
currentTourName = tourName
scope.launch {
runCatching {
val settings = store.settingsForTour(tourName).first()
currentSettings = settings
player.loadSettings(settings)
if (!settings.enabled) {
// Musik stoppen wenn Tour gewechselt (nicht wenn gleiche Tour ohne Aktivierung)
if (previousTour != tourName) {
player.stop()
}
}
Log.i(TAG, "Tour geladen: $tourName, Begleitmusik aktiviert=${settings.enabled}, autoStart=${settings.autoStartAfterWaypoint}")
}.onFailure { e ->
Log.e(TAG, "Fehler beim Laden der Tour '$tourName'", e)
}
}
}
/**
* Lädt die Einstellungen neu und wendet sie an (z. B. nach Dialog-Speichern).
*/
fun reloadCurrentTour() {
loadTour(currentTourName, force = true)
}
/**
* Startet die Begleitmusik für die aktuelle Tour (wenn aktiviert).
*/
fun startMusic() {
val s = currentSettings
if (!s.enabled) return
player.loadSettings(s)
player.play()
Log.i(TAG, "Begleitmusik gestartet")
}
/**
* Stoppt die Begleitmusik sofort.
*/
fun stopMusic() {
player.stop()
}
/**
* Pausiert die Begleitmusik.
*/
fun pauseMusic() {
player.pause()
}
/**
* Nächster Titel in der Playlist (nur für lokale Playlists).
*/
fun nextTrack() {
player.next()
}
/**
* Vorheriger Titel in der Playlist (nur für lokale Playlists).
*/
fun previousTrack() {
player.previous()
}
/**
* Speichert neue Einstellungen für eine Tour und wendet sie direkt an.
*/
fun saveAndApplySettings(tourName: String, settings: TourAudioSettings) {
scope.launch {
runCatching {
store.save(tourName, settings)
if (tourName == currentTourName) {
currentSettings = settings
player.loadSettings(settings)
if (!settings.enabled) {
player.stop()
}
}
}.onFailure { e ->
Log.e(TAG, "Fehler beim Speichern der Einstellungen für Tour '$tourName'", e)
}
}
}
/**
* Liest Einstellungen aus dem Store (suspend).
*/
suspend fun loadSettingsForTour(tourName: String): TourAudioSettings =
store.settingsForTour(tourName).first()
/**
* Vor Wegpunkt-Audio: Verhalten anwenden.
*/
fun beforeWaypointAudio() {
player.beforeWaypointAudio(currentSettings)
}
/**
* Nach Wegpunkt-Audio: Verhalten rückgängig machen / autostart anwenden.
*/
fun afterWaypointAudio() {
player.afterWaypointAudio(currentSettings)
}
/**
* Gibt alle Ressourcen frei. Danach nicht mehr nutzbar.
*/
fun release() {
scope.cancel()
player.release()
instance = null
}
}
@@ -0,0 +1,513 @@
package de.waypointaudio.service
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Zustand des Begleitmusik-Players, der in der UI angezeigt werden kann.
*/
data class MusicPlaybackState(
/** Ob gerade Musik abgespielt wird. */
val isPlaying: Boolean = false,
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean = false,
/** Titel des aktuellen Tracks (displayName oder URL). */
val currentTitle: String = "",
/** Kurzform der Quelle (z. B. "Playlist" oder Stream-URL-Hostname). */
val sourceLabel: String = "",
/** Ob die Quelle ein Stream ist (keine endliche Länge). */
val isStream: Boolean = false,
/** Wiedergabe-Position in Millisekunden (0 für Streams). */
val positionMs: Long = 0L,
/** Gesamtlänge in Millisekunden (0 für Streams oder unbekannt). */
val durationMs: Long = 0L,
/** Index des aktuellen Tracks in der Playlist (0-basiert, -1 wenn unbekannt). */
val playlistIndex: Int = -1,
/** Gesamtanzahl Tracks in der Playlist. */
val playlistTotal: Int = 0,
/** Titel des nächsten Tracks (leer wenn nicht verfügbar). */
val nextTitle: String = "",
/** Ob Previous/Next sinnvoll sind (nur lokale Playlists mit >1 Track). */
val supportsSkip: Boolean = false
)
/**
* Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer.
*
* Unterstützt:
* - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert)
* - Direkte HTTP/HTTPS Audio-Stream-URLs
* - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen
* - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint)
*
* Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen.
*
* Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen.
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
*/
class BackgroundMusicPlayer(private val context: Context) {
companion object {
private const val TAG = "BackgroundMusicPlayer"
private const val FADE_STEP_INTERVAL_MS = 50L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: ExoPlayer? = null
private var fadeJob: Job? = null
private var progressJob: Job? = null
// Aktuell geladene Einstellungen
private var currentSettings: TourAudioSettings? = null
// Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade)
private var normalVolume: Float = 1.0f
// True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird
private var waypointSuppressed: Boolean = false
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
private var wasPlayingBeforeWaypoint: Boolean = false
// Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup)
private var loadedPlaylist: List<PlaylistItem> = emptyList()
// -----------------------------------------------------------------------
// Öffentlicher State-Flow
// -----------------------------------------------------------------------
private val _playbackState = MutableStateFlow(MusicPlaybackState())
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
// -----------------------------------------------------------------------
// Öffentliche API
// -----------------------------------------------------------------------
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = player?.isPlaying == true
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean
get() = player != null
/**
* Lädt neue Einstellungen und bereitet den Player vor.
* Stoppt zuvor laufende Wiedergabe wenn Quelle sich geändert hat.
*/
fun loadSettings(settings: TourAudioSettings) {
currentSettings = settings
normalVolume = 1.0f
}
/**
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
* Baut ExoPlayer auf wenn nötig.
*/
fun play() {
val settings = currentSettings ?: return
if (!settings.enabled) return
if (player == null) {
buildPlayer(settings)
} else {
player?.play()
}
}
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
fun pause() {
cancelFade()
player?.pause()
updateState()
}
/** Stoppt Wiedergabe und gibt ExoPlayer frei. */
fun stop() {
cancelFade()
releasePlayer()
updateState()
}
/** Nächster Titel in der Playlist (nur für lokale Playlists sinnvoll). */
fun next() {
val p = player ?: return
if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Vorheriger Titel in der Playlist. */
fun previous() {
val p = player ?: return
if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Setzt die Lautstärke sofort (0.0 1.0). */
fun setVolume(volume: Float) {
player?.volume = volume.coerceIn(0f, 1f)
}
/** Gibt alle Ressourcen frei. Nach diesem Aufruf ist die Instanz nicht mehr nutzbar. */
fun release() {
scope.cancel()
releasePlayer()
}
// -----------------------------------------------------------------------
// Wegpunkt-Interaktion
// -----------------------------------------------------------------------
/**
* Vor dem Abspielen eines Wegpunkt-Audios aufrufen.
* Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter).
*/
fun beforeWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) return
wasPlayingBeforeWaypoint = isPlaying
waypointSuppressed = true
// Nur wenn player vorhanden (Musik läuft/pausiert)
if (player == null) return
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) pause()
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
fadeOut(settings.fadeDurationMs) {
player?.pause()
}
}
}
WaypointMusicBehavior.DUCK_UNDERLAY -> {
if (wasPlayingBeforeWaypoint) {
fadeToVolume(settings.duckVolume, settings.fadeDurationMs)
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
// Nichts tun Musik läuft auf normaler Lautstärke weiter
}
}
}
/**
* Nach dem Abspielen eines Wegpunkt-Audios aufrufen.
* Stellt vorherigen Zustand wieder her.
* Wenn autoStartAfterWaypoint aktiv und Musik war nicht spielend, wird sie gestartet.
*/
fun afterWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) {
waypointSuppressed = false
return
}
val wasSuppressed = waypointSuppressed
waypointSuppressed = false
if (!wasSuppressed) return
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) {
player?.play()
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
player?.volume = 0f
player?.play()
fadeIn(settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
fadeIn(settings.fadeDurationMs)
}
}
WaypointMusicBehavior.DUCK_UNDERLAY -> {
if (wasPlayingBeforeWaypoint) {
fadeToVolume(normalVolume, settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
if (shouldAutoStart) {
startOrPlay(settings)
}
}
}
}
// -----------------------------------------------------------------------
// Interner Aufbau
// -----------------------------------------------------------------------
/**
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
*/
private fun startOrPlay(settings: TourAudioSettings) {
if (player == null) {
buildPlayer(settings)
} else {
player?.play()
}
}
private fun buildPlayer(settings: TourAudioSettings) {
releasePlayer()
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val items = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist)
MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl)
}
if (items.isEmpty()) {
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
return
}
// Playlist-Snapshot für Titel-Lookup speichern
loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist
val exo = ExoPlayer.Builder(context).build().also { p ->
p.setMediaItems(items)
p.repeatMode = Player.REPEAT_MODE_ALL
p.shuffleModeEnabled = settings.shuffle
p.volume = normalVolume
p.addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
updateState()
}
override fun onIsPlayingChanged(playing: Boolean) {
updateState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
}
})
p.prepare()
p.play()
}
player = exo
Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})")
startProgressPolling()
updateState()
}
/** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */
private fun startProgressPolling() {
progressJob?.cancel()
progressJob = scope.launch {
while (isActive) {
delay(500L)
updateState()
}
}
}
/** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */
private fun updateState() {
val p = player
val settings = currentSettings
if (p == null || settings == null) {
_playbackState.value = MusicPlaybackState()
return
}
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val idx = p.currentMediaItemIndex
val total = p.mediaItemCount
val playlist = loadedPlaylist
// Titel des aktuellen Tracks
val currentTitle: String = when {
isStream -> settings.streamUrl ?: ""
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
else -> ""
}
// Titel des nächsten Tracks
val nextTitle: String = when {
isStream -> ""
playlist.size > 1 -> {
val nextIdx = (idx + 1) % playlist.size
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
}
else -> ""
}
// Quell-Label
val sourceLabel: String = when {
isStream -> {
val url = settings.streamUrl ?: ""
runCatching {
java.net.URL(url).host.ifBlank { url }
}.getOrElse { url }
}
else -> "Playlist"
}
// Position und Dauer nur für lokale Dateien mit bekannter Länge
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
val durationMs = if (!isStream) {
val d = p.duration
if (d > 0) d else 0L
} else 0L
_playbackState.value = MusicPlaybackState(
isPlaying = p.isPlaying,
hasContent = true,
currentTitle = currentTitle,
sourceLabel = sourceLabel,
isStream = isStream,
positionMs = positionMs,
durationMs = durationMs,
playlistIndex = if (!isStream) idx else -1,
playlistTotal = if (!isStream) total else 0,
nextTitle = nextTitle,
supportsSkip = !isStream && total > 1
)
}
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
return playlist.mapNotNull { item ->
runCatching {
val uri = Uri.parse(item.uriString)
// Persistable URI-Berechtigung sichern (beste-Effort)
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
MediaItem.Builder()
.setUri(uri)
.build()
}.onFailure { e ->
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
}.getOrNull()
}
}
private fun buildStreamItems(streamUrl: String?): List<MediaItem> {
if (streamUrl.isNullOrBlank()) return emptyList()
val lower = streamUrl.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl")
return emptyList()
}
return listOf(
MediaItem.Builder()
.setUri(Uri.parse(streamUrl))
.build()
)
}
private fun releasePlayer() {
cancelFade()
progressJob?.cancel()
progressJob = null
runCatching {
player?.stop()
player?.release()
}
player = null
loadedPlaylist = emptyList()
_playbackState.value = MusicPlaybackState()
}
// -----------------------------------------------------------------------
// Fade-Helfer (Koroutinen-basiert)
// -----------------------------------------------------------------------
private fun cancelFade() {
fadeJob?.cancel()
fadeJob = null
}
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
cancelFade()
val startVolume = player?.volume ?: 1f
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = startVolume / steps
var current = startVolume
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current - stepSize).coerceAtLeast(0f)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) {
player?.volume = 0f
onDone()
}
}
}
private fun fadeIn(durationMs: Long) {
cancelFade()
val targetVolume = normalVolume
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = targetVolume / steps
var current = 0f
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + stepSize).coerceAtMost(targetVolume)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
}
}
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
cancelFade()
val startVolume = player?.volume ?: normalVolume
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val delta = (targetVolume - startVolume) / steps
var current = startVolume
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + delta).coerceIn(0f, 1f)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
}
}
}
@@ -0,0 +1,220 @@
package de.waypointaudio.service
import android.content.Context
import android.content.Intent
import android.util.Log
import de.waypointaudio.data.AudioRoutingSettings
import de.waypointaudio.data.AudioRoutingStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* App-weiter Singleton-Manager für Live / PTT.
*
* Steuert den [LivePttService] (startet/stoppt den Vordergrundservice) und
* verwaltet den Status (pttActive, Gerätewahl, Fehler).
*
* Audio-Priorität:
* - Wenn PTT aktiv: Begleitmusik wird pausiert (über [BackgroundMusicManager.pauseMusic]).
* - Wegpunkt-Tracks: Der [WaypointLocationService] prüft [isActive] und verschiebt Wiedergabe.
* - Beim PTT-Ende: Begleitmusik wird wiederhergestellt (über [BackgroundMusicManager.afterWaypointAudio]).
*
* Bekannte Einschränkungen:
* - Android-Audiorouting via setPreferredDevice() wird nicht von allen Geräten unterstützt.
* - Bluetooth SCO: Erfordert ggf. AudioManager.startBluetoothSco() für volles Routing.
* Dieser MVP setzt setPreferredDevice() und loggt Fehler.
* - Echo/Feedback: Bei Nutzung des eingebauten Lautsprechers ohne Headset zu erwarten.
*/
class LivePttManager private constructor(private val appContext: Context) {
companion object {
private const val TAG = "LivePttManager"
@Volatile
private var instance: LivePttManager? = null
fun getInstance(context: Context): LivePttManager {
return instance ?: synchronized(this) {
instance ?: LivePttManager(context.applicationContext).also { instance = it }
}
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val store = AudioRoutingStore(appContext)
// ─── Öffentliche States ───────────────────────────────────────────────────
/** true wenn PTT gerade aktiv ist. */
private val _pttActive = MutableStateFlow(false)
val pttActive: StateFlow<Boolean> = _pttActive.asStateFlow()
/** Aktuell gespeicherte Routing-Einstellungen. */
private val _routingSettings = MutableStateFlow(AudioRoutingSettings())
val routingSettings: StateFlow<AudioRoutingSettings> = _routingSettings.asStateFlow()
/** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */
private val _inputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val inputDevices: StateFlow<List<AudioDeviceItem>> = _inputDevices.asStateFlow()
/** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */
private val _outputDevices = MutableStateFlow<List<AudioDeviceItem>>(emptyList())
val outputDevices: StateFlow<List<AudioDeviceItem>> = _outputDevices.asStateFlow()
/**
* Status-/Fehlermeldung für die UI.
* null = kein Fehler / kein Hinweis
*/
private val _statusMessage = MutableStateFlow<String?>(null)
val statusMessage: StateFlow<String?> = _statusMessage.asStateFlow()
// ─── Initialisierung ──────────────────────────────────────────────────────
init {
// Gespeicherte Einstellungen laden
scope.launch {
store.settings.collect { settings ->
_routingSettings.value = settings
}
}
}
// ─── PTT starten / stoppen ────────────────────────────────────────────────
/**
* Startet PTT:
* 1. Atmo pausieren
* 2. Foreground Service starten
* 3. Status setzen
*/
fun startPtt() {
if (_pttActive.value) {
Log.d(TAG, "PTT bereits aktiv kein Neustart")
return
}
// Begleitmusik pausieren
pauseAtmo()
val settings = _routingSettings.value
val intent = Intent(appContext, LivePttService::class.java).apply {
action = LivePttService.ACTION_START_PTT
settings.selectedInputDeviceId?.let {
putExtra(LivePttService.EXTRA_INPUT_DEVICE_ID, it)
}
settings.selectedOutputDeviceId?.let {
putExtra(LivePttService.EXTRA_OUTPUT_DEVICE_ID, it)
}
}
runCatching {
appContext.startForegroundService(intent)
_pttActive.value = true
_statusMessage.value = null
Log.i(TAG, "PTT gestartet")
}.onFailure { e ->
Log.e(TAG, "PTT konnte nicht gestartet werden", e)
_statusMessage.value = "PTT-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
restoreAtmo()
}
}
/**
* Stoppt PTT:
* 1. Foreground Service stoppen
* 2. Atmo wiederherstellen
* 3. Status setzen
*/
fun stopPtt() {
if (!_pttActive.value) return
val intent = Intent(appContext, LivePttService::class.java).apply {
action = LivePttService.ACTION_STOP_PTT
}
runCatching {
appContext.startService(intent)
}.onFailure { e ->
Log.w(TAG, "Fehler beim Stoppen des PTT-Service", e)
}
_pttActive.value = false
_statusMessage.value = null
restoreAtmo()
Log.i(TAG, "PTT gestoppt")
}
/** Schaltet PTT ein/aus. */
fun togglePtt() {
if (_pttActive.value) stopPtt() else startPtt()
}
// ─── Geräteverwaltung ────────────────────────────────────────────────────
/** Lädt die verfügbaren Audiogeräte neu. */
fun refreshDevices() {
_inputDevices.value = AudioDeviceManager.getInputDevices(appContext)
_outputDevices.value = AudioDeviceManager.getOutputDevices(appContext)
Log.d(TAG, "Geräte neu geladen: ${_inputDevices.value.size} Eingabe, ${_outputDevices.value.size} Ausgabe")
}
/** Speichert neue Routing-Einstellungen persistent. */
fun saveRoutingSettings(settings: AudioRoutingSettings) {
_routingSettings.value = settings
scope.launch {
runCatching {
store.save(settings)
Log.i(TAG, "Routing-Einstellungen gespeichert: Input=${settings.selectedInputDeviceId}, Output=${settings.selectedOutputDeviceId}")
}.onFailure { e ->
Log.e(TAG, "Fehler beim Speichern der Routing-Einstellungen", e)
}
}
}
/** Löscht die Statusmeldung. */
fun clearStatus() {
_statusMessage.value = null
}
// ─── Atmo-Integration ────────────────────────────────────────────────────
private fun pauseAtmo() {
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
if (mgr.player.isPlaying) {
mgr.pauseMusic()
Log.d(TAG, "Atmo pausiert für PTT")
}
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht pausieren: ${e.message}")
}
}
private fun restoreAtmo() {
runCatching {
val mgr = BackgroundMusicManager.getInstance(appContext)
val settings = mgr.currentSettings
if (settings.enabled) {
mgr.afterWaypointAudio()
Log.d(TAG, "Atmo nach PTT wiederhergestellt")
}
}.onFailure { e ->
Log.w(TAG, "Konnte Atmo nicht wiederherstellen: ${e.message}")
}
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
/** Ressourcen freigeben. Danach nicht mehr nutzbar. */
fun release() {
if (_pttActive.value) stopPtt()
scope.cancel()
instance = null
}
}
@@ -0,0 +1,275 @@
package de.waypointaudio.service
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import de.waypointaudio.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Vordergrund-Dienst für Live-Mikrofon / PTT.
*
* Beim Start (ACTION_START_PTT) wird ein AudioRecord und AudioTrack-Loop gestartet,
* der PCM-Audiodaten vom Mikrofon in Echtzeit an den Lautsprecher weiterleitet.
*
* ⚠ Bekannte Einschränkungen:
* - Echo-Feedback: Wenn kein Headset verwendet wird, hört das Mikrofon den Lautsprecher
* und erzeugt ein Echo. Für Echo-Unterdrückung ist AcousticEchoCanceler (AEC) nötig,
* das je nach Hardware verfügbar ist. Diese Implementierung verwendet keine AEC.
* - Bluetooth-Latenz: Bluetooth SCO/A2DP hat systembedingte Verzögerungen (50200 ms+).
* - USB-Geräte-IDs: Die IDs können sich nach einem Neustart ändern; bei fehlendem Gerät
* wird automatisch auf den Systemstandard zurückgefallen.
* - Android-Audiorouting: Auf manchen Geräten ignoriert das System setPreferredDevice().
* Die Ausgabe wird immer angezeigt; Fehler werden protokolliert.
*/
class LivePttService : Service() {
companion object {
const val ACTION_START_PTT = "de.waypointaudio.ptt.ACTION_START"
const val ACTION_STOP_PTT = "de.waypointaudio.ptt.ACTION_STOP"
const val EXTRA_INPUT_DEVICE_ID = "input_device_id"
const val EXTRA_OUTPUT_DEVICE_ID = "output_device_id"
private const val TAG = "LivePttService"
private const val NOTIFICATION_ID = 2001
private const val CHANNEL_ID = "ptt_channel"
// Audio-Parameter für PTT (Sprachqualität, niedrige Latenz)
private const val SAMPLE_RATE = 16_000
private const val CHANNEL_IN = android.media.AudioFormat.CHANNEL_IN_MONO
private const val CHANNEL_OUT = android.media.AudioFormat.CHANNEL_OUT_MONO
private const val ENCODING = android.media.AudioFormat.ENCODING_PCM_16BIT
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pttJob: Job? = null
private var audioRecord: AudioRecord? = null
private var audioTrack: AudioTrack? = null
// ─── Lifecycle ─────────────────────────────────────────────────────────────
override fun onCreate() {
super.onCreate()
createNotificationChannel()
Log.i(TAG, "LivePttService erstellt")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_PTT -> {
val inputDeviceId = intent.getIntExtra(EXTRA_INPUT_DEVICE_ID, -1).takeIf { it > 0 }
val outputDeviceId = intent.getIntExtra(EXTRA_OUTPUT_DEVICE_ID, -1).takeIf { it > 0 }
startPtt(inputDeviceId, outputDeviceId)
}
ACTION_STOP_PTT -> stopPttAndSelf()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopPttAndSelf()
serviceScope.cancel()
super.onDestroy()
Log.i(TAG, "LivePttService zerstört")
}
// ─── PTT-Logik ────────────────────────────────────────────────────────────
@SuppressLint("MissingPermission")
private fun startPtt(inputDeviceId: Int?, outputDeviceId: Int?) {
// Sicherstellen dass kein vorheriger Loop läuft
pttJob?.cancel()
releaseAudioResources()
startForeground(NOTIFICATION_ID, buildNotification())
pttJob = serviceScope.launch {
val bufferSize = maxOf(
AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_IN, ENCODING),
AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_OUT, ENCODING)
).coerceAtLeast(4096)
// AudioRecord initialisieren
val recorder = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE,
CHANNEL_IN,
ENCODING,
bufferSize * 2
)
// Bevorzugtes Eingabegerät setzen (falls konfiguriert)
if (inputDeviceId != null) {
val inputInfo = findInputDevice(inputDeviceId)
if (inputInfo != null) {
val ok = recorder.setPreferredDevice(inputInfo)
if (!ok) {
Log.w(TAG, "Eingabegerät (ID $inputDeviceId) konnte nicht gesetzt werden Systemstandard wird verwendet")
} else {
Log.i(TAG, "Eingabegerät gesetzt: ${inputInfo.productName} (ID $inputDeviceId)")
}
} else {
Log.w(TAG, "Eingabegerät ID $inputDeviceId nicht gefunden Systemstandard wird verwendet")
}
}
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord konnte nicht initialisiert werden")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return@launch
}
// AudioTrack initialisieren
val track = AudioTrack.Builder()
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setEncoding(ENCODING)
.setChannelMask(CHANNEL_OUT)
.build()
)
.setBufferSizeInBytes(bufferSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
// Bevorzugtes Ausgabegerät setzen (falls konfiguriert)
if (outputDeviceId != null) {
val outputInfo = findOutputDevice(outputDeviceId)
if (outputInfo != null) {
val ok = track.setPreferredDevice(outputInfo)
if (!ok) {
Log.w(TAG, "Ausgabegerät (ID $outputDeviceId) konnte nicht gesetzt werden Systemstandard wird verwendet")
} else {
Log.i(TAG, "Ausgabegerät gesetzt: ${outputInfo.productName} (ID $outputDeviceId)")
}
} else {
Log.w(TAG, "Ausgabegerät ID $outputDeviceId nicht gefunden Systemstandard wird verwendet")
}
}
audioRecord = recorder
audioTrack = track
recorder.startRecording()
track.play()
Log.i(TAG, "PTT-Schleife gestartet (Puffergröße: $bufferSize)")
val buffer = ShortArray(bufferSize / 2)
try {
while (isActive) {
val read = recorder.read(buffer, 0, buffer.size)
if (read > 0) {
track.write(buffer, 0, read)
} else if (read < 0) {
Log.w(TAG, "AudioRecord-Fehler: $read")
break
}
}
} finally {
Log.d(TAG, "PTT-Schleife beendet")
releaseAudioResources()
}
}
Log.i(TAG, "PTT gestartet (Input-Gerät: $inputDeviceId, Output-Gerät: $outputDeviceId)")
}
private fun stopPttAndSelf() {
pttJob?.cancel()
pttJob = null
releaseAudioResources()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.i(TAG, "PTT gestoppt")
}
private fun releaseAudioResources() {
runCatching {
audioRecord?.apply {
if (state == AudioRecord.STATE_INITIALIZED) {
stop()
release()
}
}
}.onFailure { Log.w(TAG, "Fehler beim Freigeben von AudioRecord: ${it.message}") }
audioRecord = null
runCatching {
audioTrack?.apply {
if (state == AudioTrack.STATE_INITIALIZED) {
stop()
release()
}
}
}.onFailure { Log.w(TAG, "Fehler beim Freigeben von AudioTrack: ${it.message}") }
audioTrack = null
}
// ─── Gerätezugriff ────────────────────────────────────────────────────────
private fun findInputDevice(deviceId: Int): AudioDeviceInfo? =
AudioDeviceManager.findInputDevice(applicationContext, deviceId)
private fun findOutputDevice(deviceId: Int): AudioDeviceInfo? =
AudioDeviceManager.findOutputDevice(applicationContext, deviceId)
// ─── Benachrichtigungen ───────────────────────────────────────────────────
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Live / PTT",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Live-Mikrofon (Push-to-Talk) aktiv"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stopIntent = PendingIntent.getService(
this, 1,
Intent(this, LivePttService::class.java).apply { action = ACTION_STOP_PTT },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Live / PTT aktiv")
.setContentText("Mikrofon wird übertragen Tippen zum Stoppen")
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_delete, "PTT stoppen", stopIntent)
.setOngoing(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
}
@@ -0,0 +1,239 @@
package de.waypointaudio.service
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
import android.util.Log
/**
* Dedicated audio player for manual (non-GPS) waypoint playback in the UI.
*
* Separate from [AudioPlayer] used by the location service so that manual
* playback and GPS-triggered playback do not interfere with each other.
*
* Supports two modes:
* - **Playlist mode**: play/pause/resume for the global manual player bar (prev/next work).
* - **Single mode**: triggered by a per-waypoint Play button. Plays exactly one file and
* stops on completion without advancing to any next item.
*
* Thread-safety: call only from a single thread (e.g. Main dispatcher).
*/
class ManualAudioPlayer {
private var mediaPlayer: MediaPlayer? = null
private var currentUri: String? = null
/**
* Whether the player is currently in single-item mode.
* In single mode, [playlistMode] is false and [singleWaypointId] holds the active waypoint ID.
*/
var isSingleMode: Boolean = false
private set
/** ID of the waypoint currently playing in single mode (null if not in single mode). */
var singleWaypointId: String? = null
private set
/** True while audio is actively playing (not paused, not stopped). */
val isPlaying: Boolean
get() = mediaPlayer?.isPlaying == true
/** True if a track is loaded (playing or paused). */
val hasTrack: Boolean
get() = mediaPlayer != null
// -------------------------------------------------------------------------
// Playlist mode (existing global manual player bar)
// -------------------------------------------------------------------------
/**
* Play or resume audio in **playlist mode**.
*
* If [soundUri] matches the currently loaded (possibly paused) track,
* this call resumes from the paused position. Otherwise a new track is loaded.
*
* Clears single mode if it was active.
*
* @param context Android context
* @param soundUri content:// or file:// URI string
* @param onCompletion called when playback finishes naturally
*/
fun play(
context: Context,
soundUri: String,
onCompletion: () -> Unit = {}
) {
if (soundUri.isBlank()) {
Log.w(TAG, "Keine Audiodatei angegeben.")
return
}
// Leaving single mode → clear single state
isSingleMode = false
singleWaypointId = null
// If same track is loaded and just paused → resume
if (soundUri == currentUri && mediaPlayer != null && !isPlaying) {
runCatching {
mediaPlayer?.start()
}.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen von $soundUri", e)
releasePlayer()
loadAndPlay(context, soundUri, onCompletion)
}
return
}
// New or different track → fresh load
releasePlayer()
loadAndPlay(context, soundUri, onCompletion)
}
// -------------------------------------------------------------------------
// Single-item mode (per-waypoint card Play button)
// -------------------------------------------------------------------------
/**
* Play a **single waypoint file** without any queue continuation.
*
* - Stops any currently active playback (playlist or single) first.
* - Sets [isSingleMode] = true and [singleWaypointId] = [waypointId].
* - On natural completion, clears both flags and calls [onCompletion].
* Does NOT advance to any next item.
* - If this waypoint is already playing in single mode, toggles pause/resume.
*
* @param context Android context
* @param waypointId Stable ID of the waypoint (for active-state tracking in UI)
* @param soundUri content:// or file:// URI string
* @param onCompletion called when playback finishes naturally (not on explicit stop/pause)
*/
fun playSingle(
context: Context,
waypointId: String,
soundUri: String,
onCompletion: () -> Unit = {}
) {
if (soundUri.isBlank()) {
Log.w(TAG, "Keine Audiodatei für Wegpunkt $waypointId angegeben.")
return
}
// Same waypoint already in single mode → toggle pause/resume
if (isSingleMode && singleWaypointId == waypointId && mediaPlayer != null) {
if (isPlaying) {
runCatching { mediaPlayer?.pause() }.onFailure { e ->
Log.e(TAG, "Fehler beim Pausieren (single mode) für $waypointId", e)
}
} else {
runCatching { mediaPlayer?.start() }.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen (single mode) für $waypointId", e)
releasePlayer()
startSingleLoad(context, waypointId, soundUri, onCompletion)
}
}
return
}
// Different waypoint or different mode → stop current and start fresh
releasePlayer()
startSingleLoad(context, waypointId, soundUri, onCompletion)
}
private fun startSingleLoad(
context: Context,
waypointId: String,
soundUri: String,
onCompletion: () -> Unit
) {
isSingleMode = true
singleWaypointId = waypointId
loadAndPlay(context, soundUri) {
// Natural completion: clear single state, then notify caller
isSingleMode = false
singleWaypointId = null
onCompletion()
}
}
// -------------------------------------------------------------------------
// Shared internal loading
// -------------------------------------------------------------------------
private fun loadAndPlay(
context: Context,
soundUri: String,
onCompletion: () -> Unit
) {
runCatching {
val uri = Uri.parse(soundUri)
mediaPlayer = MediaPlayer().apply {
// Grant URI permission for content:// URIs originating from SAF
setDataSource(context, uri)
prepare()
start()
setOnCompletionListener {
releasePlayer()
onCompletion()
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "MediaPlayer-Fehler: what=$what extra=$extra")
mp.release()
releasePlayer()
true
}
}
currentUri = soundUri
}.onFailure { e ->
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
releasePlayer()
}
}
// -------------------------------------------------------------------------
// Pause / Stop / Release
// -------------------------------------------------------------------------
/**
* Pause playback. Position is retained so [play] / [playSingle] can resume from here.
*/
fun pause() {
runCatching {
if (mediaPlayer?.isPlaying == true) {
mediaPlayer?.pause()
}
}.onFailure { e ->
Log.e(TAG, "Fehler beim Pausieren", e)
}
}
/**
* Stop and release resources. Clears current track and single-mode state.
*/
fun stop() {
releasePlayer()
}
/**
* Release MediaPlayer resources. Called on ViewModel cleared.
*/
fun release() {
releasePlayer()
}
private fun releasePlayer() {
runCatching {
mediaPlayer?.let {
if (it.isPlaying) it.stop()
it.release()
}
}
mediaPlayer = null
currentUri = null
isSingleMode = false
singleWaypointId = null
}
companion object {
private const val TAG = "ManualAudioPlayer"
}
}
@@ -0,0 +1,98 @@
package de.waypointaudio.service
import android.content.Context
import android.content.Intent
import de.waypointaudio.data.GpsTrackPoint
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Singleton-Manager für GPS-Track-Aufzeichnung im Hintergrund.
*
* Vermittelt zwischen MapScreen/ViewModel und dem TrackRecordingService.
* Hält den Aufzeichnungsstatus und die aktuell aufgezeichneten Punkte in
* StateFlows, damit die UI reaktiv aktualisiert werden kann.
*
* Nutzung:
* - TrackRecordingManager.getInstance(context) Singleton holen
* - startRecording(context, tourName) Dienst starten, StateFlows beginnen
* - stopRecording(context) Dienst stoppen, letzter Track bleibt in _currentTrack
* - Vom TrackRecordingService aufgerufen: addPoint(), setRecording()
*/
object TrackRecordingManager {
// Aufzeichnungsstatus
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
// Aktuell aufgezeichnete Track-Punkte (In-Memory)
private val _currentTrack = MutableStateFlow<List<GpsTrackPoint>>(emptyList())
val currentTrack: StateFlow<List<GpsTrackPoint>> = _currentTrack.asStateFlow()
// Name der Tour, für die gerade aufgenommen wird
private val _recordingTourName = MutableStateFlow<String?>(null)
val recordingTourName: StateFlow<String?> = _recordingTourName.asStateFlow()
/**
* Startet die Track-Aufzeichnung via TrackRecordingService.
* Setzt den internen State zurück und startet den Dienst.
*/
fun startRecording(context: Context, tourName: String) {
_currentTrack.value = emptyList()
_isRecording.value = true
_recordingTourName.value = tourName
val intent = Intent(context, TrackRecordingService::class.java).apply {
action = TrackRecordingService.ACTION_START
putExtra(TrackRecordingService.EXTRA_TOUR_NAME, tourName)
}
context.startForegroundService(intent)
}
/**
* Stoppt die Track-Aufzeichnung. Der Dienst persistiert den Track
* vor dem Stoppen. Die UI behält _currentTrack zum Anzeigen.
*/
fun stopRecording(context: Context) {
_isRecording.value = false
_recordingTourName.value = null
val intent = Intent(context, TrackRecordingService::class.java).apply {
action = TrackRecordingService.ACTION_STOP
}
context.startService(intent)
}
/**
* Fügt einen GPS-Punkt hinzu. Wird vom TrackRecordingService aufgerufen.
*/
fun addPoint(lat: Double, lng: Double) {
val point = GpsTrackPoint(latitude = lat, longitude = lng)
_currentTrack.value = _currentTrack.value + point
}
/**
* Setzt den Aufzeichnungsstatus (z. B. wenn der Dienst extern gestoppt wird).
*/
fun setRecording(recording: Boolean) {
_isRecording.value = recording
if (!recording) _recordingTourName.value = null
}
/**
* Setzt den Track (z. B. beim Laden eines persistierten Tracks beim App-Start).
*/
fun setTrack(points: List<GpsTrackPoint>) {
_currentTrack.value = points
}
/**
* Löscht den aktuellen Track im Manager (für clearTrack aus ViewModel).
*/
fun clearTrack() {
_currentTrack.value = emptyList()
_isRecording.value = false
_recordingTourName.value = null
}
}
@@ -0,0 +1,205 @@
package de.waypointaudio.service
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import de.waypointaudio.MainActivity
import de.waypointaudio.R
import de.waypointaudio.repository.WaypointRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Hintergrund-Dienst für GPS-Track-Aufzeichnung.
*
* Läuft als Foreground Service (Typ "location") und empfängt GPS-Positionen
* über FusedLocationProviderClient. Jeder empfangene Punkt wird an den
* TrackRecordingManager weitergegeben (StateFlow → UI) und beim Stoppen
* in den DataStore persistiert.
*
* Notification-ID und Channel sind bewusst von WaypointLocationService getrennt,
* damit beide Dienste unabhängig laufen können.
*/
class TrackRecordingService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.TRACK_RECORDING_START"
const val ACTION_STOP = "de.waypointaudio.TRACK_RECORDING_STOP"
const val EXTRA_TOUR_NAME = "extra_tour_name"
private const val NOTIFICATION_ID = 2001
private const val CHANNEL_ID = "track_recording_channel"
private const val TAG = "TrackRecordingService"
// GPS-Aufzeichnungsintervall: 5 Sekunden, Mindestabstand 5 Meter
private const val LOCATION_INTERVAL_MS = 5_000L
private const val LOCATION_MIN_DISTANCE_M = 5f
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var repository: WaypointRepository
private var tourName: String = ""
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { loc ->
Log.d(TAG, "GPS-Punkt: ${loc.latitude}, ${loc.longitude}")
TrackRecordingManager.addPoint(loc.latitude, loc.longitude)
}
}
}
// -------------------------------------------------------------------------
// Service-Lifecycle
// -------------------------------------------------------------------------
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
repository = WaypointRepository(applicationContext)
createNotificationChannel()
Log.i(TAG, "TrackRecordingService erstellt")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
tourName = intent.getStringExtra(EXTRA_TOUR_NAME) ?: ""
startRecording(tourName)
}
ACTION_STOP -> stopRecording()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopRecording()
serviceScope.cancel()
super.onDestroy()
Log.i(TAG, "TrackRecordingService zerstört")
}
// -------------------------------------------------------------------------
// Aufzeichnung starten / stoppen
// -------------------------------------------------------------------------
private fun startRecording(tour: String) {
val hasFine = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
if (!hasFine && !hasCoarse) {
Log.e(TAG, "Kein Standort-Berechtigung Aufzeichnung nicht möglich")
TrackRecordingManager.setRecording(false)
stopSelf()
return
}
val notification = buildNotification(tour)
startForeground(NOTIFICATION_ID, notification)
val priority = if (hasFine) Priority.PRIORITY_HIGH_ACCURACY
else Priority.PRIORITY_BALANCED_POWER_ACCURACY
val request = LocationRequest.Builder(priority, LOCATION_INTERVAL_MS)
.setMinUpdateDistanceMeters(LOCATION_MIN_DISTANCE_M)
.build()
runCatching {
fusedLocationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)
}.onFailure { e ->
Log.e(TAG, "GPS-Updates konnten nicht gestartet werden", e)
}
Log.i(TAG, "Track-Aufzeichnung gestartet für Tour: $tour")
}
private fun stopRecording() {
fusedLocationClient.removeLocationUpdates(locationCallback)
// Track persistieren bevor der Dienst endet
val points = TrackRecordingManager.currentTrack.value
if (points.isNotEmpty() && tourName.isNotBlank()) {
serviceScope.launch {
runCatching {
repository.saveGpsTrack(tourName, points)
Log.i(TAG, "Track gespeichert: ${points.size} Punkte für Tour '$tourName'")
}.onFailure { e ->
Log.e(TAG, "Fehler beim Speichern des GPS-Tracks", e)
}
}
}
TrackRecordingManager.setRecording(false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.i(TAG, "Track-Aufzeichnung gestoppt")
}
// -------------------------------------------------------------------------
// Benachrichtigung
// -------------------------------------------------------------------------
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.track_recording_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.track_recording_channel_description)
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
private fun buildNotification(tour: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val tourLabel = if (tour.isNotBlank()) " $tour" else ""
val contentText = getString(R.string.track_recording_notification_text, tourLabel)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.track_recording_notification_title))
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
}
@@ -0,0 +1,458 @@
package de.waypointaudio.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import de.waypointaudio.MainActivity
import de.waypointaudio.R
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.TourMusicStore
import de.waypointaudio.data.Waypoint
import de.waypointaudio.repository.WaypointRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
// PTT-Manager (Singleton, lazy, null-safe)
/**
* Vordergrund-Dienst fuer GPS-Ueberwachung und Wegpunkt-Erkennung.
*
* Architektur:
* - Empfaengt Standort-Updates via FusedLocationProviderClient
* - Laedt Wegpunkte aus dem Repository
* - Berechnet fuer jeden Wegpunkt die Entfernung
* - Spielt die zugehoerige Audiodatei ab, wenn der Radius betreten wird
* - Verhindert wiederholte Wiedergabe bis der Benutzer die Zone verlassen hat
* - Beruecksichtigt Abspielregeln: EVERY_ENTRY, ONCE, LIMITED_COUNT + Zeitplan
*
* Begleitmusik-Sequenz (GPS-Trigger):
* 1. beforeWaypointAudio() wird vor der Wiedergabe aufgerufen (Pause/Fade/Duck)
* 2. AudioPlayer spielt den Wegpunkt-Ton ab
* 3. Nach natuerlichem Ende ODER Fehler ruft der onCompletion-/onError-Callback
* afterWaypointAudio() auf - niemals sofort nach dem play()-Aufruf.
* Callbacks laufen auf dem MediaPlayer-Thread und werden im Main-Dispatcher
* ausgefuehrt (ExoPlayer erfordert Main-Thread).
* 4. playCount wird nur bei natuerlicher Completion inkrementiert (nicht bei Fehler,
* nicht wenn Wiedergabe gar nicht starten konnte).
*/
class WaypointLocationService : Service() {
companion object {
const val ACTION_START = "de.waypointaudio.ACTION_START"
const val ACTION_STOP = "de.waypointaudio.ACTION_STOP"
private const val NOTIFICATION_ID = 1001
private const val CHANNEL_ID = "waypoint_service_channel"
private const val TAG = "WaypointLocationService"
// GPS-Aktualisierungsintervall
private const val LOCATION_INTERVAL_MS = 5_000L
private const val LOCATION_MIN_INTERVAL_MS = 2_000L
}
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var repository: WaypointRepository
private lateinit var musicStore: TourMusicStore
private val audioPlayer = AudioPlayer()
// Begleitmusik-Manager aus dem App-Prozess (null-safe wenn nicht initialisiert)
private val musicManager: BackgroundMusicManager?
get() = runCatching { BackgroundMusicManager.getInstance(applicationContext) }.getOrNull()
// PTT-Manager null-safe
private val pttManager: LivePttManager?
get() = runCatching { LivePttManager.getInstance(applicationContext) }.getOrNull()
// Letzter aufgeschobener Wegpunkt (während PTT aktiv war)
private var pendingPttWaypoint: Waypoint? = null
// Set von Wegpunkt-IDs, bei denen der Nutzer gerade im Radius ist
// (verhindert mehrfaches Ausloesen)
private val insideIds = mutableSetOf<String>()
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { location ->
checkWaypoints(location)
}
}
}
// ---------------------------------------------------------------------------
// Service-Lifecycle
// ---------------------------------------------------------------------------
override fun onCreate() {
super.onCreate()
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
repository = WaypointRepository(applicationContext)
musicStore = TourMusicStore(applicationContext)
createNotificationChannel()
Log.i(TAG, "Service erstellt")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startTracking()
ACTION_STOP -> stopTracking()
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopTracking()
serviceScope.cancel()
super.onDestroy()
Log.i(TAG, "Service zerstoert")
}
// ---------------------------------------------------------------------------
// Tracking starten / stoppen
// ---------------------------------------------------------------------------
private fun startTracking() {
val notification = buildNotification(getString(R.string.notification_text))
startForeground(NOTIFICATION_ID, notification)
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, LOCATION_INTERVAL_MS)
.setMinUpdateIntervalMillis(LOCATION_MIN_INTERVAL_MS)
.build()
runCatching {
fusedLocationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)
}.onFailure { e ->
Log.e(TAG, "Standort-Updates konnten nicht gestartet werden", e)
}
Log.i(TAG, "GPS-Tracking gestartet")
}
private fun stopTracking() {
fusedLocationClient.removeLocationUpdates(locationCallback)
audioPlayer.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.i(TAG, "GPS-Tracking gestoppt")
}
// ---------------------------------------------------------------------------
// Wegpunkt-Pruefung
// ---------------------------------------------------------------------------
private fun checkWaypoints(location: Location) {
serviceScope.launch {
val waypoints: List<Waypoint> = runCatching {
repository.waypoints.first()
}.getOrDefault(emptyList())
val currentInside = mutableSetOf<String>()
for (wp in waypoints) {
if (!wp.isActive) continue
val results = FloatArray(1)
Location.distanceBetween(
location.latitude, location.longitude,
wp.latitude, wp.longitude,
results
)
val distanceM = results[0]
if (distanceM <= wp.radiusMeters) {
currentInside.add(wp.id)
// Nur ausloesen, wenn zuvor ausserhalb (Eintrittsereignis)
if (!insideIds.contains(wp.id)) {
Log.i(TAG, "Wegpunkt betreten: ${wp.name} (Entfernung: ${distanceM.toInt()}m)")
onWaypointEntered(wp)
}
}
}
// IDs aktualisieren (verlassene Zonen entfernen)
insideIds.retainAll(currentInside)
insideIds.addAll(currentInside)
}
}
private fun onWaypointEntered(waypoint: Waypoint) {
// Benachrichtigung aktualisieren
val msg = getString(R.string.notification_waypoint_reached, waypoint.name)
updateNotification(msg)
// Ton nur abspielen wenn Audiodatei vorhanden und Abspielregeln erfuellt
if (waypoint.soundUri.isBlank()) {
Log.d(TAG, "Kein Ton fuer Wegpunkt '${waypoint.name}' - keine Audiodatei")
return
}
if (!isPlaybackAllowed(waypoint)) {
Log.i(TAG, "Ton nicht abgespielt - Abspielregel oder Zeitplan verhindert Wiedergabe: ${waypoint.name}")
return
}
// PTT-Sperre: Wenn PTT aktiv, Wegpunkt aufschoeben und spaeter abspielen
val ptt = pttManager
if (ptt != null && ptt.pttActive.value) {
Log.i(TAG, "PTT aktiv - Wegpunkt '${waypoint.name}' wird aufgeschoben")
pendingPttWaypoint = waypoint
// Sobald PTT inaktiv wird: verschobenen Wegpunkt abspielen
serviceScope.launch(Dispatchers.Main) {
ptt.pttActive.collect { active ->
if (!active) {
val pending = pendingPttWaypoint
pendingPttWaypoint = null
if (pending != null) {
Log.i(TAG, "PTT beendet - verschobenen Wegpunkt '${pending.name}' wird abgespielt")
playWaypointAudio(pending)
}
// Collector einmalig beenden (kein further collecting noetig)
return@collect
}
}
}
return
}
playWaypointAudio(waypoint)
}
/**
* Spielt den Wegpunkt-Ton ab (Begleitmusik-Sequenz).
* Kann direkt oder nach PTT-Ende aufgerufen werden.
*/
private fun playWaypointAudio(waypoint: Waypoint) {
// Begleitmusik-Sequenz:
// 1. beforeWaypointAudio() - Begleitmusik pausieren/ducken/faden
// 2. audioPlayer.play() - Wegpunkt-Ton starten
// 3a. onCompletion - nach naturalem Ende: afterWaypointAudio() + playCount++
// 3b. onError - bei Fehler: afterWaypointAudio() (kein playCount++)
//
// Alles laeuft im Main-Dispatcher, da ExoPlayer (Begleitmusik) Main erfordert.
// Die Callbacks von AudioPlayer kommen auf dem MediaPlayer-Thread und werden
// per serviceScope.launch(Dispatchers.Main) weitergeleitet.
serviceScope.launch(Dispatchers.Main) {
val mgr = musicManager
// Tour-Einstellungen laden
val settings: TourAudioSettings? = if (mgr != null) {
runCatching {
val tourName = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
musicStore.settingsForTour(tourName).first()
}.getOrNull()
} else null
// 1. Begleitmusik vor Wegpunkt-Audio anpassen
if (mgr != null && settings != null && settings.enabled) {
runCatching {
mgr.currentSettings = settings
mgr.beforeWaypointAudio()
}.onFailure { e ->
Log.w(TAG, "Begleitmusik before-Fehler: ${e.message}")
}
}
// 2. Wegpunkt-Ton abspielen
// onCompletion und onError werden auf dem MediaPlayer-Thread aufgerufen;
// wir leiten sie zurueck in den Main-Dispatcher fuer ExoPlayer-Zugriffe.
audioPlayer.play(
context = applicationContext,
soundUri = waypoint.soundUri,
onCompletion = {
// Natuerliches Ende: Begleitmusik wiederherstellen + Zaehler erhoehen
serviceScope.launch(Dispatchers.Main) {
Log.d(TAG, "Wegpunkt-Ton beendet (natural completion): ${waypoint.name}")
restoreBackgroundMusic(mgr, settings)
}
// playCount auf IO-Thread inkrementieren (Repository-Zugriff)
serviceScope.launch(Dispatchers.IO) {
runCatching {
repository.incrementPlayCount(waypoint.id)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Inkrementieren von playCount fuer ${waypoint.name}", e)
}
}
},
onError = { e ->
// Fehler: Begleitmusik trotzdem wiederherstellen; kein playCount++
serviceScope.launch(Dispatchers.Main) {
Log.w(TAG, "Wegpunkt-Ton Fehler (${waypoint.name}): ${e?.message}")
restoreBackgroundMusic(mgr, settings)
}
}
)
}
}
/**
* Stellt die Begleitmusik nach dem Wegpunkt-Ton wieder her.
* Muss auf dem Main-Thread aufgerufen werden (ExoPlayer-Anforderung).
*/
private fun restoreBackgroundMusic(
mgr: BackgroundMusicManager?,
settings: TourAudioSettings?
) {
if (mgr == null || settings == null || !settings.enabled) return
runCatching {
mgr.afterWaypointAudio()
}.onFailure { e ->
Log.w(TAG, "Begleitmusik after-Fehler: ${e.message}")
}
}
// ---------------------------------------------------------------------------
// Abspielregel-Pruefung
// ---------------------------------------------------------------------------
/**
* Prueft ob die Abspielregeln und der Zeitplan die Wiedergabe erlauben.
*
* Logik:
* 1. Modus-Pruefung:
* - EVERY_ENTRY: immer erlaubt (solange Zeitplan passt)
* - ONCE: nur wenn playCount == 0
* - LIMITED_COUNT: nur wenn playCount < maxPlayCount (null/0 kein Abspielen)
* 2. Zeitplan (nur wenn scheduleEnabled):
* - scheduleStartMillis: aktuelle Zeit muss >= Start sein
* - scheduleEndMillis: aktuelle Zeit muss <= Ende sein
* - allowedStartMinutes / allowedEndMinutes: Tagesminute muss im Fenster liegen
* (Mitternachts-uebergreifende Fenster werden unterstuetzt)
*/
private fun isPlaybackAllowed(waypoint: Waypoint): Boolean {
val nowMillis = System.currentTimeMillis()
// --- Modus-Pruefung ---
val modeAllowed = when (waypoint.playbackMode) {
PlaybackMode.EVERY_ENTRY -> true
PlaybackMode.ONCE -> waypoint.playCount == 0
PlaybackMode.LIMITED_COUNT -> {
val max = waypoint.maxPlayCount
if (max == null || max <= 0) {
// Keine gueltige Obergrenze kein Abspielen
false
} else {
waypoint.playCount < max
}
}
}
if (!modeAllowed) return false
// --- Zeitplan-Pruefung (nur wenn aktiv) ---
if (!waypoint.scheduleEnabled) return true
// Datum/Zeit-Fenster: wenn gesetzt, aktuelle Zeit muss im Bereich liegen
waypoint.scheduleStartMillis?.let { start ->
if (nowMillis < start) {
Log.d(TAG, "Zeitplan: Startzeit noch nicht erreicht fuer '${waypoint.name}'")
return false
}
}
waypoint.scheduleEndMillis?.let { end ->
if (nowMillis > end) {
Log.d(TAG, "Zeitplan: Endzeitpunkt ueberschritten fuer '${waypoint.name}'")
return false
}
}
// Taegliches Zeitfenster (Tagesminuten)
val startMin = waypoint.allowedStartMinutes
val endMin = waypoint.allowedEndMinutes
if (startMin != null || endMin != null) {
val cal = Calendar.getInstance()
val currentMinuteOfDay = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
if (startMin != null && endMin != null) {
val inWindow = if (startMin <= endMin) {
// Normales Fenster, z. B. 08:00-20:00
currentMinuteOfDay in startMin..endMin
} else {
// Mitternachts-uebergreifend, z. B. 22:00-06:00
currentMinuteOfDay >= startMin || currentMinuteOfDay <= endMin
}
if (!inWindow) {
Log.d(TAG, "Zeitplan: Ausserhalb des Tages-Zeitfensters fuer '${waypoint.name}'")
return false
}
} else if (startMin != null) {
if (currentMinuteOfDay < startMin) {
Log.d(TAG, "Zeitplan: Vor dem Tages-Start fuer '${waypoint.name}'")
return false
}
} else if (endMin != null) {
if (currentMinuteOfDay > endMin) {
Log.d(TAG, "Zeitplan: Nach dem Tages-Ende fuer '${waypoint.name}'")
return false
}
}
}
return true
}
// ---------------------------------------------------------------------------
// Benachrichtigungen
// ---------------------------------------------------------------------------
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW // lautlos, keine Vibration
).apply {
description = getString(R.string.notification_channel_description)
setShowBadge(false)
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
private fun buildNotification(contentText: String): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
}
@@ -0,0 +1,198 @@
package de.waypointaudio.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
/**
* Info-Dialog: Zeigt App-Name, Beschreibung, Entwickler, E-Mail, Version
* und eine kurze Zusammenfassung der Apache License 2.0.
*/
@Composable
fun AboutDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(R.string.about_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// App name + description
Text(
text = stringResource(R.string.about_app_name),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.about_description),
style = MaterialTheme.typography.bodyMedium
)
HorizontalDivider()
// Version
AboutRow(
label = stringResource(R.string.about_version_label),
value = stringResource(R.string.about_version)
)
// Developer
AboutRow(
label = stringResource(R.string.about_developer_label),
value = stringResource(R.string.about_developer)
)
// Email - preserved exactly as provided by developer
AboutRow(
label = stringResource(R.string.about_email_label),
value = stringResource(R.string.about_email)
)
HorizontalDivider()
// License
Text(
text = stringResource(R.string.about_license_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_license_name),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.about_license_summary),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(R.string.about_license_url),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
HorizontalDivider()
// Matrix contact
Text(
text = stringResource(R.string.about_matrix_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = stringResource(R.string.about_matrix_description),
style = MaterialTheme.typography.bodyMedium
)
// Matrix ID as selectable text
Text(
text = stringResource(R.string.about_matrix_id),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// QR code
Box(
modifier = Modifier
.padding(top = 4.dp)
.background(
color = Color.White,
shape = MaterialTheme.shapes.small
)
.padding(8.dp)
.align(Alignment.CenterHorizontally)
) {
Image(
painter = painterResource(id = R.drawable.matrix_contact_qr),
contentDescription = stringResource(R.string.about_matrix_qr_content_description),
modifier = Modifier.size(200.dp)
)
}
// Clickable button to open Matrix chat in browser or Matrix client
val openCd = stringResource(R.string.about_matrix_open_cd)
val matrixUrl = stringResource(R.string.about_matrix_url)
Button(
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(matrixUrl)
)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
// Fallback: explicit chooser for browser
try {
context.startActivity(Intent.createChooser(intent, null))
} catch (_: ActivityNotFoundException) {
// No browser/client available - silently ignore
}
}
},
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = openCd }
) {
Text(text = stringResource(R.string.about_matrix_open_button))
}
HorizontalDivider()
Text(
text = stringResource(R.string.about_copyright),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.about_close))
}
}
)
}
@Composable
private fun AboutRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}
@@ -0,0 +1,483 @@
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.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.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)
@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()
// 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) }
var streamUrl by remember(initialSettings) { mutableStateOf(initialSettings.streamUrl ?: "") }
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) }
var streamUrlError by remember { mutableStateOf("") }
// 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) {
val url = streamUrl.trim()
if (url.isBlank()) {
streamUrlError = context.getString(R.string.music_stream_url_empty_error)
return false
}
val lower = url.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
streamUrlError = context.getString(R.string.music_stream_url_invalid_error)
return false
}
}
streamUrlError = ""
val settings = TourAudioSettings(
enabled = enabled,
sourceType = sourceType,
localPlaylist = localPlaylist,
streamUrl = streamUrl.trim().ifBlank { null },
behavior = behavior,
fadeDurationMs = fadeDurationMs,
duckVolume = duckVolume,
shuffle = shuffle,
autoStartAfterWaypoint = autoStartAfterWaypoint
)
viewModel.saveMusicSettings(settings)
return true
}
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()) {
Text(
stringResource(R.string.music_playlist_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
} 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
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
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))
}
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-URL ───────────────────────────────────────────────
if (sourceType == MusicSourceType.STREAM_URL) {
OutlinedTextField(
value = streamUrl,
onValueChange = { streamUrl = it; streamUrlError = "" },
label = { Text(stringResource(R.string.music_stream_url_label)) },
placeholder = { Text("https://stream.example.com/radio") },
isError = streamUrlError.isNotBlank(),
supportingText = {
if (streamUrlError.isNotBlank()) {
Text(streamUrlError, 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()
)
// 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))
}
}
)
}
@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)
)
}
}
}
@@ -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))
}
}
)
}
@@ -0,0 +1,903 @@
package de.waypointaudio.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddLocation
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Timeline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import de.waypointaudio.R
import de.waypointaudio.data.GpsTrackPoint
import de.waypointaudio.data.Waypoint
import de.waypointaudio.viewmodel.WaypointViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.compass.CompassOverlay
import org.osmdroid.views.overlay.compass.InternalCompassOrientationProvider
import java.io.File
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Karten-Editor mit osmdroid (OpenStreetMap).
*
* Funktionen:
* - Zeigt alle Wegpunkte der gewählten Tour mit Marker + Radius-Kreis.
* - Antippen eines Wegpunkt-Markers → Aktionsmenü (Bearbeiten / Löschen).
* - Langes Drücken auf die Karte → neuer Wegpunkt an dieser Position.
* - FAB „Mein Standort" → zentriert Karte, zeigt Puck-Marker.
* - FAB „Als Wegpunkt" → neuer Wegpunkt aus der ermittelten GPS-Position.
* - GPS-Track-Aufzeichnung: Start / Stopp, Polyline, Statistik (Punkte, Distanz, Zeit).
* - Track wird gespeichert und beim erneuten Öffnen der Tour wieder angezeigt.
* - „Wegpunkt am letzten Trackpunkt" erstellt neuen Wegpunkt am Track-Ende.
*
* Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL)
*/
@SuppressLint("MissingPermission")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapScreen(
viewModel: WaypointViewModel,
onNavigateBack: () -> Unit
) {
val allWaypoints by viewModel.waypoints.collectAsState()
val selectedTour by viewModel.selectedTour.collectAsState()
val tourList by viewModel.tourList.collectAsState()
val tourDefaults by viewModel.tourDefaults.collectAsState()
val currentTrack by viewModel.currentTrack.collectAsState()
val isRecording by viewModel.isRecording.collectAsState()
// Nur Wegpunkte der gewählten Tour anzeigen
val waypoints = allWaypoints.filter {
it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour
}
val context = LocalContext.current
val primaryColor = MaterialTheme.colorScheme.primary
val errorColor = MaterialTheme.colorScheme.error
val trackColor = MaterialTheme.colorScheme.tertiary
val scope = rememberCoroutineScope()
// osmdroid konfigurieren
LaunchedEffect(Unit) {
Configuration.getInstance().apply {
userAgentValue = context.packageName
osmdroidTileCache = File(context.cacheDir, "osmdroid")
}
}
var mapView by remember { mutableStateOf<MapView?>(null) }
var myLocationMarker by remember { mutableStateOf<Marker?>(null) }
var currentLocation by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showAddWaypointAction by remember { mutableStateOf(false) }
// Editor-Dialog-State
var editDialogWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var editDialogPrefill by remember { mutableStateOf<Pair<Double, Double>?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
// Wegpunkt-Aktionsmenü (Marker antippen)
var actionMenuWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// Single-play state for map dialog button
val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState()
// GPS-Track-Zeiterfassung
var recordingStartMs by remember { mutableLongStateOf(0L) }
var elapsedSeconds by remember { mutableLongStateOf(0L) }
// Bestätigungsdialog für das Löschen eines Wegpunkts
var deleteConfirmWaypoint by remember { mutableStateOf<Waypoint?>(null) }
// Bitmap für Standort-Puck (einmalig erstellt)
val puckBitmap = remember { createLocationPuckBitmap(context) }
val fusedLocationClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
// ─── Karte neu zeichnen wenn Wegpunkte / Track / Farben sich ändern ────────
LaunchedEffect(waypoints, primaryColor, currentTrack) {
val mv = mapView ?: return@LaunchedEffect
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
// ─── Sekunden-Timer während Aufzeichnung ────────────────────────────────
LaunchedEffect(isRecording) {
if (isRecording) {
recordingStartMs = System.currentTimeMillis()
while (isRecording) {
elapsedSeconds = (System.currentTimeMillis() - recordingStartMs) / 1000L
kotlinx.coroutines.delay(1000L)
}
}
}
// ─── GPS-Track-Aufzeichnung via Foreground Service ────────────────────
// Der TrackRecordingService läuft als Foreground Service und sendet
// GPS-Punkte an TrackRecordingManager → currentTrack / isRecording StateFlows.
// Die UI beobachtet diese Flows bereits oben via viewModel.currentTrack / isRecording.
// ─── Editor-Dialog ───────────────────────────────────────────────────────
if (showEditDialog) {
WaypointEditDialog(
waypoint = editDialogWaypoint,
prefillLatLng = if (editDialogWaypoint == null) editDialogPrefill else null,
existingTours = tourList,
prefillTourName = selectedTour,
tourDefaults = tourDefaults,
onConfirm = { newWaypoint ->
viewModel.upsert(newWaypoint)
showEditDialog = false
editDialogWaypoint = null
editDialogPrefill = null
showAddWaypointAction = false
},
onDismiss = {
showEditDialog = false
editDialogWaypoint = null
editDialogPrefill = null
}
)
}
// ─── Wegpunkt-Aktionsmenü ────────────────────────────────────────────────
actionMenuWaypoint?.let { wp ->
AlertDialog(
onDismissRequest = { actionMenuWaypoint = null },
title = {
Text(
text = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) },
style = MaterialTheme.typography.titleMedium
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
// Play single button only shown when audio is assigned
if (wp.soundUri.isNotBlank()) {
val isThisLoaded = singlePlayingWaypointId == wp.id
Button(
onClick = { viewModel.manualPlaySingle(wp) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.PlayCircle,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
if (isThisLoaded)
stringResource(R.string.waypoint_pause_single)
else
stringResource(R.string.waypoint_play_single)
)
}
}
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Bearbeiten
Button(onClick = {
editDialogWaypoint = wp
editDialogPrefill = null
showEditDialog = true
actionMenuWaypoint = null
}) {
Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.map_waypoint_edit))
}
// Löschen öffnet Bestätigungsdialog
Button(
onClick = {
deleteConfirmWaypoint = wp
actionMenuWaypoint = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.map_waypoint_delete))
}
}
},
dismissButton = {
TextButton(onClick = { actionMenuWaypoint = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
// ─── Bestätigungsdialog: Wegpunkt löschen ────────────────────────────────
deleteConfirmWaypoint?.let { wp ->
AlertDialog(
onDismissRequest = { deleteConfirmWaypoint = null },
title = {
Text(
text = stringResource(R.string.confirm_delete_waypoint_title),
style = MaterialTheme.typography.titleMedium
)
},
text = {
val name = wp.name.ifBlank { stringResource(R.string.map_waypoint_unnamed) }
Text(
text = stringResource(R.string.confirm_delete_waypoint_msg, name),
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(
onClick = {
viewModel.delete(wp.id)
deleteConfirmWaypoint = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { deleteConfirmWaypoint = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(stringResource(R.string.map_view))
if (tourList.size > 1) {
Text(
text = selectedTour,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// FAB: Als Wegpunkt übernehmen (aktueller Standort)
AnimatedVisibility(
visible = showAddWaypointAction && currentLocation != null,
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
ExtendedFloatingActionButton(
onClick = {
editDialogPrefill = currentLocation
editDialogWaypoint = null
showEditDialog = true
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
icon = { Icon(Icons.Filled.AddLocation, contentDescription = null) },
text = { Text(stringResource(R.string.add_waypoint_from_map)) }
)
}
// FAB: Wegpunkt am letzten Track-Punkt
AnimatedVisibility(
visible = currentTrack.isNotEmpty(),
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
ExtendedFloatingActionButton(
onClick = {
val last = currentTrack.lastOrNull()
if (last != null) {
editDialogPrefill = Pair(last.latitude, last.longitude)
editDialogWaypoint = null
showEditDialog = true
}
},
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
icon = { Icon(Icons.Filled.Timeline, contentDescription = null) },
text = { Text(stringResource(R.string.map_track_waypoint_at_last)) }
)
}
// FAB: GPS-Track aufnehmen / stoppen
if (isRecording) {
ExtendedFloatingActionButton(
onClick = { viewModel.stopTrackRecording() },
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
icon = { Icon(Icons.Filled.Stop, contentDescription = null) },
text = { Text(stringResource(R.string.map_track_stop)) }
)
} else {
FloatingActionButton(
onClick = { viewModel.startTrackRecording() },
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
) {
Icon(Icons.Filled.FiberManualRecord, contentDescription = stringResource(R.string.map_track_start))
}
}
// FAB: Mein Standort
FloatingActionButton(
onClick = {
scope.launch {
val hasFine = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
val mv = mapView ?: return@launch
if (hasFine || hasCoarse) {
val priority = if (hasFine) Priority.PRIORITY_HIGH_ACCURACY
else Priority.PRIORITY_BALANCED_POWER_ACCURACY
val lastLoc = runCatching {
fusedLocationClient.lastLocation.await()
}.getOrNull()
if (lastLoc != null) {
val gp = GeoPoint(lastLoc.latitude, lastLoc.longitude)
mv.controller.animateTo(gp)
mv.controller.setZoom(16.0)
currentLocation = Pair(lastLoc.latitude, lastLoc.longitude)
showAddWaypointAction = true
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
}
val cts = CancellationTokenSource()
val freshLoc = runCatching {
fusedLocationClient.getCurrentLocation(priority, cts.token).await()
}.getOrNull()
if (freshLoc != null) {
val gp = GeoPoint(freshLoc.latitude, freshLoc.longitude)
mv.controller.animateTo(gp)
mv.controller.setZoom(16.0)
currentLocation = Pair(freshLoc.latitude, freshLoc.longitude)
showAddWaypointAction = true
myLocationMarker = updateMyLocationMarker(mv, gp, myLocationMarker, puckBitmap)
}
} else {
if (waypoints.isNotEmpty()) {
val first = waypoints.first()
mv.controller.animateTo(GeoPoint(first.latitude, first.longitude))
mv.controller.setZoom(15.0)
}
}
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(
Icons.Filled.MyLocation,
contentDescription = stringResource(R.string.my_location),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
// ─── OSM-Karte ───────────────────────────────────────────────────
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
controller.setZoom(12.0)
val center = if (waypoints.isNotEmpty()) {
GeoPoint(waypoints.first().latitude, waypoints.first().longitude)
} else if (currentTrack.isNotEmpty()) {
GeoPoint(currentTrack.last().latitude, currentTrack.last().longitude)
} else {
GeoPoint(51.1657, 10.4515) // Deutschlandmitte
}
controller.setCenter(center)
// Kompass-Overlay
val compass = CompassOverlay(
ctx,
InternalCompassOrientationProvider(ctx),
this
)
compass.enableCompass()
overlays.add(compass)
// Long-Press → neuer Wegpunkt
val eventsReceiver = object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?) = false
override fun longPressHelper(p: GeoPoint?): Boolean {
if (p != null) {
editDialogPrefill = Pair(p.latitude, p.longitude)
editDialogWaypoint = null
showEditDialog = true
}
return true
}
}
overlays.add(MapEventsOverlay(eventsReceiver))
mapView = this
drawMapOverlays(
mv = this,
waypoints = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
},
update = { mv ->
mapView = mv
drawMapOverlays(
mv = mv,
waypoints = waypoints,
trackPoints = currentTrack,
primaryArgb = primaryColor.toArgb(),
errorArgb = errorColor.toArgb(),
trackArgb = trackColor.toArgb(),
onWaypointTapped = { wp -> actionMenuWaypoint = wp }
)
}
)
// ─── Koordinaten-Infoband (GPS-Position) ─────────────────────────
AnimatedVisibility(
visible = showAddWaypointAction && currentLocation != null,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp),
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
) {
currentLocation?.let { (lat, lng) ->
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.92f),
shadowElevation = 4.dp,
modifier = Modifier.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.MyLocation,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
Text(
"%.7f, %.7f".format(Locale.US, lat, lng),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// ─── Track-Statistik-Panel ───────────────────────────────────────
AnimatedVisibility(
visible = isRecording || currentTrack.isNotEmpty(),
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 12.dp, bottom = 12.dp),
enter = fadeIn() + slideInVertically(initialOffsetY = { it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it })
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = if (isRecording)
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.93f)
else
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.93f),
shadowElevation = 4.dp
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
// Aufnahme-Indikator
Row(verticalAlignment = Alignment.CenterVertically) {
if (isRecording) {
Icon(
Icons.Filled.FiberManualRecord,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.map_track_recording),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
} else {
Icon(
Icons.Filled.Timeline,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.width(4.dp))
Text(
stringResource(R.string.map_track_saved),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(4.dp))
// Statistik
val distM = trackDistanceMeters(currentTrack)
val distStr = if (distM >= 1000)
"%.1f km".format(distM / 1000.0)
else
"${distM.toInt()} m"
Text(
stringResource(
R.string.map_track_stats,
currentTrack.size,
distStr
),
style = MaterialTheme.typography.bodySmall,
color = if (isRecording)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
)
if (isRecording) {
Text(
formatElapsed(elapsedSeconds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
// Track löschen (nur wenn nicht gerade aufgenommen)
if (!isRecording && currentTrack.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { viewModel.clearTrack() },
modifier = Modifier.height(28.dp)
) {
Text(
stringResource(R.string.map_track_clear),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// ─── Editor-Hinweis (unten mittig) ──────────────────────────────
AnimatedVisibility(
visible = !isRecording && currentTrack.isEmpty(),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
enter = fadeIn(),
exit = fadeOut()
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f),
shadowElevation = 2.dp
) {
Text(
text = stringResource(R.string.map_editor_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp)
)
}
}
}
}
DisposableEffect(Unit) {
onDispose {
mapView?.onDetach()
}
}
}
// ---------------------------------------------------------------------------
// Overlays zeichnen (Wegpunkte + Track-Polyline)
// ---------------------------------------------------------------------------
private fun drawMapOverlays(
mv: MapView,
waypoints: List<Waypoint>,
trackPoints: List<GpsTrackPoint>,
primaryArgb: Int,
errorArgb: Int,
trackArgb: Int,
onWaypointTapped: (Waypoint) -> Unit
) {
val overlays = mv.overlays
// Radius-Polygone, Wegpunkt-Marker und Track-Polylines entfernen
overlays.removeAll(overlays.filterIsInstance<Polygon>().toSet())
val waypointMarkers = overlays.filterIsInstance<Marker>()
.filter { it.title != "Mein Standort" }
overlays.removeAll(waypointMarkers.toSet())
overlays.removeAll(overlays.filterIsInstance<Polyline>().toSet())
// GPS-Track zeichnen
if (trackPoints.size >= 2) {
val polyline = Polyline(mv).apply {
setPoints(trackPoints.map { GeoPoint(it.latitude, it.longitude) })
outlinePaint.color = trackArgb
outlinePaint.strokeWidth = 6f
outlinePaint.alpha = 200
}
overlays.add(polyline)
}
// Wegpunkte zeichnen
for (wp in waypoints) {
val geoPoint = GeoPoint(wp.latitude, wp.longitude)
// Radius-Kreis
val circle = buildRadiusCircle(geoPoint, wp.radiusMeters.toDouble(), primaryArgb)
overlays.add(circle)
// Marker mit Tap-Handler
val marker = Marker(mv).apply {
position = geoPoint
title = wp.name
snippet = "%.6f, %.6f · %dm".format(Locale.US, wp.latitude, wp.longitude, wp.radiusMeters.toInt())
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
alpha = if (wp.isActive) 1.0f else 0.4f
// infoWindow = null → Tap löst onMarkerClick aus
setOnMarkerClickListener { _, _ ->
onWaypointTapped(wp)
true
}
}
overlays.add(marker)
}
mv.invalidate()
}
// ---------------------------------------------------------------------------
// Hilfsfunktionen: Standort-Puck, Marker, Radius-Kreis, Distanz
// ---------------------------------------------------------------------------
private fun createLocationPuckBitmap(context: android.content.Context): Bitmap? {
return runCatching {
val drawable = ContextCompat.getDrawable(context, R.drawable.ic_my_location_puck)
?: return@runCatching null
val wrapped = DrawableCompat.wrap(drawable).mutate()
val size = (48 * context.resources.displayMetrics.density).toInt()
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
wrapped.setBounds(0, 0, canvas.width, canvas.height)
wrapped.draw(canvas)
bitmap
}.getOrNull()
}
private fun updateMyLocationMarker(
mapView: MapView,
position: GeoPoint,
existingMarker: Marker?,
puckBitmap: Bitmap?
): Marker {
if (existingMarker != null) {
mapView.overlays.remove(existingMarker)
}
val marker = Marker(mapView).apply {
this.position = position
title = "Mein Standort"
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
if (puckBitmap != null) {
icon = android.graphics.drawable.BitmapDrawable(
mapView.context.resources,
puckBitmap
)
}
infoWindow = null
}
mapView.overlays.add(marker)
mapView.invalidate()
return marker
}
private fun buildRadiusCircle(center: GeoPoint, radiusMeters: Double, colorArgb: Int): Polygon {
val points = mutableListOf<GeoPoint>()
val steps = 64
val earthRadius = 6_371_000.0
val lat0 = Math.toRadians(center.latitude)
val lon0 = Math.toRadians(center.longitude)
val d = radiusMeters / earthRadius
for (i in 0 until steps) {
val bearing = Math.toRadians(i * 360.0 / steps)
val lat1 = Math.asin(
sin(lat0) * cos(d) + cos(lat0) * sin(d) * cos(bearing)
)
val lon1 = lon0 + atan2(
sin(bearing) * sin(d) * cos(lat0),
cos(d) - sin(lat0) * sin(lat1)
)
points.add(GeoPoint(Math.toDegrees(lat1), Math.toDegrees(lon1)))
}
points.add(points.first())
return Polygon().apply {
this.points = points
val alpha30 = (colorArgb and 0x00FFFFFF) or 0x4D000000
fillColor = alpha30
strokeColor = colorArgb
strokeWidth = 2f
isVisible = true
}
}
/**
* Berechnet die Gesamtdistanz eines GPS-Tracks in Metern (Haversine).
*/
private fun trackDistanceMeters(points: List<GpsTrackPoint>): Double {
if (points.size < 2) return 0.0
val R = 6_371_000.0
var total = 0.0
for (i in 1 until points.size) {
val p1 = points[i - 1]
val p2 = points[i]
val dLat = Math.toRadians(p2.latitude - p1.latitude)
val dLon = Math.toRadians(p2.longitude - p1.longitude)
val a = sin(dLat / 2).pow(2) +
cos(Math.toRadians(p1.latitude)) * cos(Math.toRadians(p2.latitude)) *
sin(dLon / 2).pow(2)
total += 2 * R * atan2(sqrt(a), sqrt(1 - a))
}
return total
}
/** Formatiert Sekunden als MM:SS oder HH:MM:SS. */
private fun formatElapsed(totalSeconds: Long): String {
val h = TimeUnit.SECONDS.toHours(totalSeconds)
val m = TimeUnit.SECONDS.toMinutes(totalSeconds) % 60
val s = totalSeconds % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s)
else "%02d:%02d".format(m, s)
}
@@ -0,0 +1,84 @@
package de.waypointaudio.ui
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Bildschirm, der angezeigt wird, wenn Berechtigungen fehlen.
* Leitet den Nutzer zur Systemeinstellung weiter.
*/
@Composable
fun PermissionRequiredScreen(onPermissionsGranted: () -> Unit) {
val context = LocalContext.current
val permissions = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(Manifest.permission.READ_MEDIA_AUDIO)
} else {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
// Hinweis: Im finalen Projekt ggf. accompanist-permissions verwenden.
// Hier direkte Navigationsanweisung zur App-Einstellung.
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Filled.LocationOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(64.dp)
)
Spacer(Modifier.height(24.dp))
Text(
"Berechtigungen erforderlich",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
"Für GPS2Audio werden Standort- und Benachrichtigungsberechtigungen benötigt.\n\n" +
"Bitte erteilen Sie diese in den App-Einstellungen und starten Sie die App neu.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(Modifier.height(24.dp))
Button(onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}) {
Text("Einstellungen öffnen")
}
Spacer(Modifier.height(12.dp))
OutlinedButton(onClick = onPermissionsGranted) {
Text("Erneut prüfen")
}
}
}
@@ -0,0 +1,270 @@
package de.waypointaudio.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Loop
import androidx.compose.material.icons.filled.LooksOne
import androidx.compose.material.icons.filled.Numbers
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourPlaybackDefaults
/**
* Dialog für Tour-weite Zähler-Aktionen.
*
* Erlaubt:
* - Abspiel-Modus für alle Wegpunkte der Tour zu setzen (bei jedem Betreten / nur einmal / begrenzt oft)
* - Zähler zurücksetzen
* - Tour-Vorgaben für neue Wegpunkte werden beim Anwenden gespeichert (Hinweis im Dialog)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TourCounterDialog(
tourName: String,
wayPointCount: Int,
totalPlayed: Int,
currentDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
onApply: (mode: PlaybackMode, maxCount: Int?, resetCounts: Boolean) -> Unit,
onReset: () -> Unit,
onDismiss: () -> Unit
) {
var selectedMode by remember { mutableStateOf(currentDefaults.playbackMode) }
var maxCountText by remember {
mutableStateOf(
if (currentDefaults.maxPlayCount > 0) currentDefaults.maxPlayCount.toString() else "3"
)
}
var maxCountError by remember { mutableStateOf(false) }
var resetCounts by remember { mutableStateOf(false) }
var modeMenuExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text(
text = stringResource(R.string.tour_counter_dialog_title),
style = MaterialTheme.typography.titleLarge
)
Text(
text = tourName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Aggregate info row
if (wayPointCount > 0) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.tour_counter_waypoints, wayPointCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.tour_counter_total_played, totalPlayed),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
HorizontalDivider()
// Reset button standalone action
Text(
text = stringResource(R.string.tour_counter_reset_section),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
OutlinedButton(
onClick = {
onReset()
onDismiss()
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.tour_counter_reset_button))
}
HorizontalDivider()
// Mode selection section
Text(
text = stringResource(R.string.tour_counter_mode_section),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Mode dropdown
ExposedDropdownMenuBox(
expanded = modeMenuExpanded,
onExpandedChange = { modeMenuExpanded = it }
) {
OutlinedTextField(
value = playbackModeLabel(selectedMode),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.tour_counter_mode_label)) },
leadingIcon = {
Icon(
imageVector = when (selectedMode) {
PlaybackMode.EVERY_ENTRY -> Icons.Filled.Loop
PlaybackMode.ONCE -> Icons.Filled.LooksOne
PlaybackMode.LIMITED_COUNT -> Icons.Filled.Numbers
},
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = modeMenuExpanded,
onDismissRequest = { modeMenuExpanded = false }
) {
PlaybackMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(playbackModeLabel(mode)) },
leadingIcon = {
Icon(
imageVector = when (mode) {
PlaybackMode.EVERY_ENTRY -> Icons.Filled.Loop
PlaybackMode.ONCE -> Icons.Filled.LooksOne
PlaybackMode.LIMITED_COUNT -> Icons.Filled.Numbers
},
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
onClick = {
selectedMode = mode
maxCountError = false
modeMenuExpanded = false
}
)
}
}
}
// Max count field only for LIMITED_COUNT
if (selectedMode == PlaybackMode.LIMITED_COUNT) {
OutlinedTextField(
value = maxCountText,
onValueChange = {
maxCountText = it
maxCountError = false
},
label = { Text(stringResource(R.string.playback_max_count_label)) },
placeholder = { Text(stringResource(R.string.playback_max_count_hint)) },
isError = maxCountError,
supportingText = if (maxCountError) {
{ Text(stringResource(R.string.playback_max_count_error)) }
} else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
// Reset counters checkbox
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = resetCounts,
onCheckedChange = { resetCounts = it }
)
Spacer(Modifier.width(4.dp))
Text(
text = stringResource(R.string.tour_counter_reset_with_apply),
style = MaterialTheme.typography.bodyMedium
)
}
// Hint: applies to existing + saved as default for new waypoints
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = stringResource(R.string.tour_counter_apply_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}
}
},
confirmButton = {
Button(
onClick = {
if (selectedMode == PlaybackMode.LIMITED_COUNT) {
val n = maxCountText.trim().toIntOrNull()
if (n == null || n < 1) {
maxCountError = true
return@Button
}
onApply(selectedMode, n, resetCounts)
} else {
onApply(selectedMode, null, resetCounts)
}
onDismiss()
}
) {
Text(stringResource(R.string.tour_counter_apply_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
private fun playbackModeLabel(mode: PlaybackMode): String = when (mode) {
PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry)
PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once)
PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited)
}
@@ -0,0 +1,772 @@
package de.waypointaudio.ui
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import java.util.Calendar
import java.util.Locale
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.*
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.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.waypointaudio.R
import de.waypointaudio.data.PlaybackMode
import de.waypointaudio.data.TourPlaybackDefaults
import de.waypointaudio.data.Waypoint
import java.text.SimpleDateFormat
import java.util.Date
// Display formats
private val DISPLAY_DATE_FORMAT = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMAN)
private val DISPLAY_TIME_FORMAT = SimpleDateFormat("HH:mm", Locale.GERMAN)
/** Formats Long? (milliseconds) as "dd.MM.yyyy HH:mm" or empty. */
private fun Long?.toDisplayDateString(): String =
if (this == null) "" else DISPLAY_DATE_FORMAT.format(Date(this))
/** Formats Int? (day minute) as "HH:mm" or empty. */
private fun Int?.toDisplayTimeString(): String {
if (this == null) return ""
val h = this / 60
val m = this % 60
return "%02d:%02d".format(h, m)
}
/**
* Dialog for creating and editing a waypoint.
*
* @param waypoint Existing waypoint (null = create new)
* @param prefillLatLng Pre-filled GPS coordinates (lat, lon).
* Only used for new waypoints (waypoint == null).
* @param existingTours List of existing tour names for the dropdown.
* @param prefillTourName Tour to pre-select for new waypoints (e.g. currently active tab).
* @param tourDefaults Tour-wide playback defaults inherited by new waypoints.
* @param onConfirm Callback with the configured waypoint
* @param onDismiss Callback on cancel
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WaypointEditDialog(
waypoint: Waypoint?,
prefillLatLng: Pair<Double, Double>? = null,
existingTours: List<String> = emptyList(),
prefillTourName: String = Waypoint.DEFAULT_TOUR_NAME,
tourDefaults: TourPlaybackDefaults = TourPlaybackDefaults(),
onConfirm: (Waypoint) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val isNew = waypoint == null
// Coordinate pre-fill: GPS position takes priority for new waypoints
val initialLat = when {
waypoint != null -> "%.7f".format(Locale.US, waypoint.latitude)
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.first)
else -> ""
}
val initialLng = when {
waypoint != null -> "%.7f".format(Locale.US, waypoint.longitude)
prefillLatLng != null -> "%.7f".format(Locale.US, prefillLatLng.second)
else -> ""
}
// Basic field state
var name by remember { mutableStateOf(waypoint?.name ?: "") }
var latStr by remember { mutableStateOf(initialLat) }
var lngStr by remember { mutableStateOf(initialLng) }
var radiusStr by remember { mutableStateOf(waypoint?.radiusMeters?.toInt()?.toString() ?: "50") }
var soundUri by remember { mutableStateOf(waypoint?.soundUri ?: "") }
var soundName by remember { mutableStateOf(waypoint?.soundName ?: "") }
// Tour name: use existing waypoint's tour, or prefill for new, fallback to default
val initialTour = when {
waypoint != null -> waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }
prefillTourName.isNotBlank() -> prefillTourName
else -> Waypoint.DEFAULT_TOUR_NAME
}
var tourName by remember { mutableStateOf(initialTour) }
var tourMenuExpanded by remember { mutableStateOf(false) }
// Build the selectable tour list: existing tours + current value if not yet in list
val selectableTours = remember(existingTours, tourName) {
val base = if (existingTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else existingTours
if (tourName !in base) base + tourName else base
}
// Basic field validation errors
var nameError by remember { mutableStateOf(false) }
var latError by remember { mutableStateOf(false) }
var lngError by remember { mutableStateOf(false) }
var radiusError by remember { mutableStateOf(false) }
// --- Playback rules state inherit tour defaults for new waypoints ---
val defaultMode = if (waypoint == null) tourDefaults.playbackMode else waypoint.playbackMode
val defaultMaxCount = if (waypoint == null && tourDefaults.playbackMode == PlaybackMode.LIMITED_COUNT)
tourDefaults.maxPlayCount.toString() else waypoint?.maxPlayCount?.toString() ?: ""
var playbackMode by remember { mutableStateOf(defaultMode) }
var maxPlayCountStr by remember {
mutableStateOf(defaultMaxCount)
}
var maxPlayCountError by remember { mutableStateOf(false) }
var currentPlayCount by remember { mutableStateOf(waypoint?.playCount ?: 0) }
// Scheduler: stored as millis / minutes (nullable = not set)
var scheduleEnabled by remember { mutableStateOf(waypoint?.scheduleEnabled ?: false) }
var scheduleStartMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleStartMillis) }
var scheduleEndMillis by remember { mutableStateOf<Long?>(waypoint?.scheduleEndMillis) }
var allowedStartMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedStartMinutes) }
var allowedEndMinutes by remember { mutableStateOf<Int?>(waypoint?.allowedEndMinutes) }
// Schedule validation errors
var scheduleRangeError by remember { mutableStateOf(false) }
// GPS pre-fill on change
LaunchedEffect(prefillLatLng) {
if (isNew && prefillLatLng != null) {
latStr = "%.7f".format(Locale.US, prefillLatLng.first)
lngStr = "%.7f".format(Locale.US, prefillLatLng.second)
}
}
// Audio file picker
val audioPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
soundUri = uri.toString()
soundName = uri.lastPathSegment?.substringAfterLast('/')?.substringAfterLast(':')
?: uri.toString().takeLast(30)
}
}
// Helper: open DatePickerDialog followed by TimePickerDialog, result via callback
fun showDateTimePicker(
initialMillis: Long?,
onResult: (Long) -> Unit
) {
val cal = Calendar.getInstance().also { c ->
if (initialMillis != null) c.timeInMillis = initialMillis
}
DatePickerDialog(
context,
{ _, year, month, day ->
// After date is picked, open time picker
TimePickerDialog(
context,
{ _, hour, minute ->
val result = Calendar.getInstance().apply {
set(Calendar.YEAR, year)
set(Calendar.MONTH, month)
set(Calendar.DAY_OF_MONTH, day)
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
onResult(result.timeInMillis)
},
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
true // 24-hour format
).show()
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
// Helper: open TimePickerDialog, result in minutes (0..1439)
fun showTimePicker(
initialMinutes: Int?,
onResult: (Int) -> Unit
) {
val h = (initialMinutes ?: 0) / 60
val m = (initialMinutes ?: 0) % 60
TimePickerDialog(
context,
{ _, hour, minute ->
onResult(hour * 60 + minute)
},
h, m,
true // 24-hour format
).show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(if (isNew) R.string.add_waypoint else R.string.edit_waypoint)
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// GPS pre-fill notice
if (isNew && prefillLatLng != null) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
"📍 Koordinaten aus aktuellem GPS-Standort vorausgefüllt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
// Tour-Vorgaben-Hinweis für neue Wegpunkte
if (isNew && tourDefaults.playbackMode != de.waypointaudio.data.PlaybackMode.EVERY_ENTRY) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = stringResource(R.string.waypoint_inherits_tour_defaults),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it; nameError = false },
label = { Text(stringResource(R.string.waypoint_name)) },
isError = nameError,
supportingText = if (nameError) {{ Text("Pflichtfeld") }} else null,
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ---------------------------------------------------------------
// Tour-Zuordnung
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.tour_section_label),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// Tour dropdown selectableTours als Auswahlhilfe,
// aber freie Texteingabe ist ebenfalls möglich
ExposedDropdownMenuBox(
expanded = tourMenuExpanded,
onExpandedChange = { tourMenuExpanded = it }
) {
OutlinedTextField(
value = tourName,
onValueChange = { tourName = it },
label = { Text(stringResource(R.string.tour_name_label)) },
placeholder = { Text(Waypoint.DEFAULT_TOUR_NAME) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = tourMenuExpanded)
},
singleLine = true,
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = tourMenuExpanded,
onDismissRequest = { tourMenuExpanded = false }
) {
selectableTours.forEach { t ->
DropdownMenuItem(
text = { Text(t) },
onClick = {
tourName = t
tourMenuExpanded = false
}
)
}
}
}
Text(
text = stringResource(R.string.tour_name_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Coordinates
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = latStr,
onValueChange = { latStr = it; latError = false },
label = { Text("Lat") },
isError = latError,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = lngStr,
onValueChange = { lngStr = it; lngError = false },
label = { Text("Lng") },
isError = lngError,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
// Coordinates format hint
Text(
"Dezimalgrad, z. B. 48.137154 · 11.576124",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
// Radius
OutlinedTextField(
value = radiusStr,
onValueChange = { radiusStr = it; radiusError = false },
label = { Text(stringResource(R.string.waypoint_radius)) },
isError = radiusError,
supportingText = if (radiusError) {{ Text("Ganzzahl > 0") }} else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Audio file selection
OutlinedButton(
onClick = { audioPickerLauncher.launch(arrayOf("audio/*")) },
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Filled.AudioFile,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.choose_sound))
}
if (soundName.isNotBlank()) {
Text(
text = soundName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 2
)
} else {
Text(
text = stringResource(R.string.no_sound_selected),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f)
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// ---------------------------------------------------------------
// Playback rules
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.playback_rules_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
// Mode selection via DropdownMenu
var modeMenuExpanded by remember { mutableStateOf(false) }
val modeLabel = when (playbackMode) {
PlaybackMode.EVERY_ENTRY -> stringResource(R.string.playback_mode_every_entry)
PlaybackMode.ONCE -> stringResource(R.string.playback_mode_once)
PlaybackMode.LIMITED_COUNT -> stringResource(R.string.playback_mode_limited)
}
ExposedDropdownMenuBox(
expanded = modeMenuExpanded,
onExpandedChange = { modeMenuExpanded = it }
) {
OutlinedTextField(
value = modeLabel,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.playback_mode_label)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = modeMenuExpanded)
},
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = modeMenuExpanded,
onDismissRequest = { modeMenuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_every_entry)) },
onClick = {
playbackMode = PlaybackMode.EVERY_ENTRY
modeMenuExpanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_once)) },
onClick = {
playbackMode = PlaybackMode.ONCE
modeMenuExpanded = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.playback_mode_limited)) },
onClick = {
playbackMode = PlaybackMode.LIMITED_COUNT
modeMenuExpanded = false
}
)
}
}
// Max count only visible for LIMITED_COUNT
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
OutlinedTextField(
value = maxPlayCountStr,
onValueChange = { maxPlayCountStr = it; maxPlayCountError = false },
label = { Text(stringResource(R.string.playback_max_count_label)) },
placeholder = { Text(stringResource(R.string.playback_max_count_hint)) },
isError = maxPlayCountError,
supportingText = if (maxPlayCountError) {
{ Text(stringResource(R.string.playback_max_count_error)) }
} else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
// Counter display + reset (edit mode only)
if (!isNew) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.playback_count_info, currentPlayCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
TextButton(onClick = { currentPlayCount = 0 }) {
Text(
text = stringResource(R.string.playback_count_reset),
style = MaterialTheme.typography.bodySmall
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// ---------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------
Text(
text = stringResource(R.string.schedule_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.schedule_enabled_label),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = scheduleEnabled,
onCheckedChange = { scheduleEnabled = it }
)
}
if (scheduleEnabled) {
// ---- Start date/time ----
Text(
text = stringResource(R.string.schedule_start_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showDateTimePicker(scheduleStartMillis) { millis ->
scheduleStartMillis = millis
scheduleRangeError = false
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (scheduleStartMillis != null)
scheduleStartMillis.toDisplayDateString()
else
stringResource(R.string.schedule_pick_start),
maxLines = 1
)
}
if (scheduleStartMillis != null) {
TextButton(onClick = {
scheduleStartMillis = null
scheduleRangeError = false
}) {
Text(stringResource(R.string.schedule_clear_start))
}
}
}
// ---- End date/time ----
Text(
text = stringResource(R.string.schedule_end_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showDateTimePicker(scheduleEndMillis) { millis ->
scheduleEndMillis = millis
scheduleRangeError = false
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (scheduleEndMillis != null)
scheduleEndMillis.toDisplayDateString()
else
stringResource(R.string.schedule_pick_end),
maxLines = 1
)
}
if (scheduleEndMillis != null) {
TextButton(onClick = {
scheduleEndMillis = null
scheduleRangeError = false
}) {
Text(stringResource(R.string.schedule_clear_end))
}
}
}
// Range validation error
if (scheduleRangeError) {
Text(
text = stringResource(R.string.schedule_end_before_start_error),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
// ---- Daily time window ----
Text(
text = stringResource(R.string.schedule_daily_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Daily start time
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showTimePicker(allowedStartMinutes) { minutes ->
allowedStartMinutes = minutes
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (allowedStartMinutes != null)
allowedStartMinutes.toDisplayTimeString()
else
stringResource(R.string.schedule_pick_daily_start),
maxLines = 1
)
}
if (allowedStartMinutes != null) {
TextButton(onClick = { allowedStartMinutes = null }) {
Text(stringResource(R.string.schedule_clear_window))
}
}
}
// Daily end time
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedButton(
onClick = {
showTimePicker(allowedEndMinutes) { minutes ->
allowedEndMinutes = minutes
}
},
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Filled.Schedule,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (allowedEndMinutes != null)
allowedEndMinutes.toDisplayTimeString()
else
stringResource(R.string.schedule_pick_daily_end),
maxLines = 1
)
}
if (allowedEndMinutes != null) {
TextButton(onClick = { allowedEndMinutes = null }) {
Text(stringResource(R.string.schedule_clear_window))
}
}
}
// Info: daily window can cross midnight
Text(
text = stringResource(R.string.schedule_daily_midnight_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f)
)
}
}
},
confirmButton = {
TextButton(onClick = {
// --- Basic validation ---
nameError = name.isBlank()
val lat = latStr.replace(',', '.').toDoubleOrNull()
val lng = lngStr.replace(',', '.').toDoubleOrNull()
val radius = radiusStr.replace(',', '.').toFloatOrNull()
latError = lat == null || lat !in -90.0..90.0
lngError = lng == null || lng !in -180.0..180.0
radiusError = radius == null || radius <= 0f
// --- Tour name validation: fallback to default if blank ---
val safeTourName = tourName.trim().ifBlank { Waypoint.DEFAULT_TOUR_NAME }
// --- Playback rule validation ---
var maxCount: Int? = null
if (playbackMode == PlaybackMode.LIMITED_COUNT) {
maxCount = maxPlayCountStr.trim().toIntOrNull()
maxPlayCountError = (maxCount == null || maxCount < 1)
} else {
maxPlayCountError = false
}
// --- Schedule validation ---
scheduleRangeError = if (scheduleEnabled) {
scheduleStartMillis != null &&
scheduleEndMillis != null &&
scheduleEndMillis!! < scheduleStartMillis!!
} else false
val hasError = nameError || latError || lngError || radiusError ||
maxPlayCountError || scheduleRangeError
if (!hasError) {
onConfirm(
(waypoint ?: Waypoint()).copy(
name = name.trim(),
latitude = lat!!,
longitude = lng!!,
radiusMeters = radius!!,
soundUri = soundUri,
soundName = soundName,
tourName = safeTourName,
// Playback rules
playbackMode = playbackMode,
maxPlayCount = if (playbackMode == PlaybackMode.LIMITED_COUNT) maxCount else null,
playCount = currentPlayCount,
scheduleEnabled = scheduleEnabled,
scheduleStartMillis = if (scheduleEnabled) scheduleStartMillis else null,
scheduleEndMillis = if (scheduleEnabled) scheduleEndMillis else null,
allowedStartMinutes = if (scheduleEnabled) allowedStartMinutes else null,
allowedEndMinutes = if (scheduleEnabled) allowedEndMinutes else null
)
)
}
}) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
package de.waypointaudio.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// ─────────────────────────────────────────────────────────────────────────────
// GPS2Audio Professional Color Palette
//
// Light: off-white surface, dark-navy primary, teal/cyan accent
// Dark: deep charcoal background, teal primary, lighter cyan accent
// ─────────────────────────────────────────────────────────────────────────────
// Shared accent tokens
private val Teal600 = Color(0xFF0B7B82) // primary light
private val Teal800 = Color(0xFF004F54) // primaryContainer light
private val Teal200 = Color(0xFF80CBC4) // primaryContainer content light
private val Cyan300 = Color(0xFF4DB6AC) // primary dark
private val Navy900 = Color(0xFF0D1B2A) // darkest navy
private val LightColors = lightColorScheme(
primary = Teal600, // #0B7B82 action & top-bar
onPrimary = Color.White,
primaryContainer = Color(0xFFB2DFDB), // muted teal container
onPrimaryContainer = Color(0xFF003D40),
secondary = Color(0xFF1C5461), // dark teal secondary
onSecondary = Color.White,
secondaryContainer = Color(0xFFCCE8EE),
onSecondaryContainer = Color(0xFF001F26),
tertiary = Color(0xFF437A22), // green for GPS-active accent
onTertiary = Color.White,
tertiaryContainer = Color(0xFFC5E6A6),
onTertiaryContainer = Color(0xFF0F2000),
background = Color(0xFFF4F6F7), // very slight blue-grey tint
onBackground = Color(0xFF0D1B2A),
surface = Color(0xFFF9FBFC),
onSurface = Color(0xFF0D1B2A),
surfaceVariant = Color(0xFFDDE8EA),
onSurfaceVariant = Color(0xFF3A4F52),
outline = Color(0xFF7AA0A5),
error = Color(0xFFA12C7B),
onError = Color.White,
errorContainer = Color(0xFFFFD8ED),
onErrorContainer = Color(0xFF3A0030)
)
private val DarkColors = darkColorScheme(
primary = Cyan300, // #4DB6AC readable on dark
onPrimary = Color(0xFF00312F),
primaryContainer = Color(0xFF004F54),
onPrimaryContainer = Color(0xFFB2DFDB),
secondary = Color(0xFF80CBC4), // lighter teal
onSecondary = Color(0xFF003D40),
secondaryContainer = Color(0xFF004F54),
onSecondaryContainer = Color(0xFFB2DFDB),
tertiary = Color(0xFF6DAA45), // green accent
onTertiary = Color(0xFF1B3700),
tertiaryContainer = Color(0xFF2B5200),
onTertiaryContainer = Color(0xFFC5E6A6),
background = Color(0xFF0D1B2A), // deep navy
onBackground = Color(0xFFCFD9DC),
surface = Color(0xFF132030), // slightly lighter navy
onSurface = Color(0xFFCFD9DC),
surfaceVariant = Color(0xFF1E3040),
onSurfaceVariant = Color(0xFF8EB3BC),
outline = Color(0xFF4A7880),
error = Color(0xFFD163A7),
onError = Color(0xFF680045),
errorContainer = Color(0xFF930060),
onErrorContainer = Color(0xFFFFD8ED)
)
@Composable
fun WaypointAudioTheme(
darkTheme: Boolean = false,
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content
)
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
GPS2Audio launcher icon
Concept: map pin silhouette (GPS) with sound-wave arcs (Audio)
Background: deep navy #0D1B2A | Foreground: teal #4DB6AC + white
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background fill deep navy -->
<path
android:fillColor="#0D1B2A"
android:pathData="M0,0h108v108h-108z" />
<!-- Map-pin teardrop body teal -->
<path
android:fillColor="#4DB6AC"
android:pathData="
M54,16
C43.5,16 35,24.5 35,35
C35,50 54,76 54,76
C54,76 73,50 73,35
C73,24.5 64.5,16 54,16Z" />
<!-- Pin inner circle navy (cut-out effect) -->
<path
android:fillColor="#0D1B2A"
android:pathData="M54,28 a7,7 0 1 0 0.001 0Z" />
<!-- Sound-wave arc 1 small, white, right of pin -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M68,46 Q73,52 68,58" />
<!-- Sound-wave arc 2 medium, white -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M72,43 Q80,52 72,61" />
<!-- Sound-wave arc 3 large, teal -->
<path
android:strokeColor="#80CBC4"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M76,40 Q87,52 76,64" />
</vector>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Modern location puck icon for "Mein Standort" marker on the map.
Blue/teal circular puck with white inner dot and outer accuracy ring.
Designed to look like a modern GPS location indicator, clearly distinct
from the default osmdroid waypoint markers.
ViewBox: 48×48 dp
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- Outer accuracy / pulse ring (semi-transparent teal) -->
<path
android:fillColor="#3300BCD4"
android:pathData="M24,24 m-20,0 a20,20 0 1,0 40,0 a20,20 0 1,0 -40,0" />
<!-- Mid ring border (white, subtle shadow) -->
<path
android:fillColor="#CCFFFFFF"
android:pathData="M24,24 m-14,0 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0" />
<!-- Main filled circle (Material teal / blue) -->
<path
android:fillColor="#0288D1"
android:pathData="M24,24 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
<!-- Inner white dot (center) -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M24,24 m-5,0 a5,5 0 1,0 10,0 a5,5 0 1,0 -10,0" />
</vector>
+310
View File
@@ -0,0 +1,310 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">GPS2Audio</string>
<!-- Notification (Wegpunkt-Dienst) -->
<string name="notification_channel_id">waypoint_service_channel</string>
<string name="notification_channel_name">Wegpunkt-Ortungsdienst</string>
<string name="notification_channel_description">Läuft im Hintergrund, um Wegpunkte zu erkennen</string>
<string name="notification_title">GPS2Audio aktiv</string>
<string name="notification_text">Überwache GPS-Wegpunkte…</string>
<string name="notification_waypoint_reached">Wegpunkt erreicht: %1$s</string>
<!-- Notification (Track-Aufzeichnung) -->
<string name="track_recording_channel_name">GPS-Track-Aufzeichnung</string>
<string name="track_recording_channel_description">Läuft im Hintergrund während der GPS-Track aufgezeichnet wird</string>
<string name="track_recording_notification_title">GPS2Audio Track-Aufzeichnung</string>
<string name="track_recording_notification_text">GPS-Track wird aufgezeichnet%1$s</string>
<string name="track_recording_permission_missing">Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert.</string>
<!-- UI strings -->
<string name="add_waypoint">Wegpunkt hinzufügen</string>
<string name="edit_waypoint">Wegpunkt bearbeiten</string>
<string name="delete_waypoint">Wegpunkt löschen</string>
<string name="no_waypoints">Noch keine Wegpunkte angelegt.</string>
<string name="waypoint_name">Name</string>
<string name="waypoint_latitude">Breitengrad (Latitude)</string>
<string name="waypoint_longitude">Längengrad (Longitude)</string>
<string name="waypoint_radius">Radius (Meter)</string>
<string name="waypoint_sound">Audiodatei</string>
<string name="choose_sound">Audiodatei auswählen</string>
<string name="no_sound_selected">Keine Audiodatei ausgewählt</string>
<string name="save">Speichern</string>
<string name="cancel">Abbrechen</string>
<string name="delete">Löschen</string>
<string name="confirm_delete_title">Wegpunkt löschen?</string>
<string name="confirm_delete_msg">Dieser Wegpunkt wird unwiderruflich gelöscht.</string>
<string name="confirm_delete_waypoint_title">Wegpunkt löschen?</string>
<string name="confirm_delete_waypoint_msg">„%1$s“ wird unwiderruflich gelöscht.</string>
<string name="service_start">Dienst starten</string>
<string name="service_stop">Dienst stoppen</string>
<string name="permission_required">Berechtigungen erforderlich</string>
<string name="permission_location_rationale">Für die Wegpunkt-Erkennung werden Standort-Berechtigungen benötigt.</string>
<string name="permission_background_rationale">Für Hintergrund-Erkennung wird die Berechtigung für Standortzugriff im Hintergrund benötigt. Bitte in den Einstellungen aktivieren.</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="use_current_location">Aktuellen Standort verwenden</string>
<string name="back">Zurück</string>
<string name="my_location">Meinen Standort anzeigen</string>
<!-- Map -->
<string name="map_view">Karte</string>
<string name="map_attribution">Kartendaten © OpenStreetMap-Mitwirkende</string>
<!-- Map: Als Wegpunkt übernehmen -->
<string name="add_waypoint_from_map">Als Wegpunkt übernehmen</string>
<string name="current_location_hint">Koordinaten aus aktuellem GPS-Standort vorausgefüllt.</string>
<!-- Map Editor -->
<string name="map_editor_hint">Marker antippen · Karte lange drücken</string>
<string name="map_waypoint_edit">Bearbeiten</string>
<string name="map_waypoint_delete">Löschen</string>
<string name="map_waypoint_unnamed">Unbenannter Wegpunkt</string>
<!-- Map: GPS-Track-Aufzeichnung -->
<string name="map_track_start">Track aufzeichnen</string>
<string name="map_track_stop">Aufzeichnung stoppen</string>
<string name="map_track_recording">Aufzeichnung läuft</string>
<string name="map_track_saved">Track gespeichert</string>
<string name="map_track_clear">Track löschen</string>
<string name="map_track_waypoint_at_last">Wegpunkt am Track-Ende</string>
<string name="map_track_stats">%1$d Punkte · %2$s</string>
<!-- Import / Export -->
<string name="export_json">Als JSON exportieren</string>
<string name="import_json">JSON importieren</string>
<string name="export_gpx">Als GPX exportieren</string>
<string name="import_gpx">GPX importieren</string>
<string name="import_export_success">Erfolgreich</string>
<!-- GPS Waypoint -->
<string name="add_waypoint_from_location">Wegpunkt aus aktuellem Standort</string>
<string name="location_loading">GPS-Position wird ermittelt…</string>
<!-- About / Info -->
<string name="menu_about">Über diese App</string>
<string name="about_title">Über diese App</string>
<string name="about_app_name">GPS2Audio</string>
<string name="about_description">Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden.</string>
<string name="about_version_label">Version</string>
<string name="about_version">1.0</string>
<string name="about_developer_label">Entwickler</string>
<string name="about_developer">Marcel Mayer</string>
<string name="about_email_label">E-Mail</string>
<string name="about_email">marcel.mayer@nesohub.org</string>
<string name="about_license_label">Lizenz</string>
<string name="about_license_name">Apache License 2.0</string>
<string name="about_license_summary">Diese App ist Open Source und wird unter der Apache License 2.0 veröffentlicht. Nutzung, Veränderung, Forks und Weitergabe sind unter den Bedingungen dieser Lizenz erlaubt. Copyright- und Lizenzhinweise müssen erhalten bleiben. Die Software wird ohne Gewährleistung bereitgestellt.</string>
<string name="about_license_url">https://www.apache.org/licenses/LICENSE-2.0</string>
<string name="about_close">Schließen</string>
<string name="about_copyright">Copyright 2026 Marcel Mayer</string>
<!-- Matrix contact -->
<string name="about_matrix_label">Matrix-Kontakt</string>
<string name="about_matrix_description">Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren.</string>
<string name="about_matrix_id">@neso:nesohub.org</string>
<string name="about_matrix_url">https://matrix.to/#/@neso:nesohub.org</string>
<string name="about_matrix_open_button">Matrix-Chat öffnen</string>
<string name="about_matrix_open_cd">Matrix-Chat mit @neso:nesohub.org öffnen</string>
<string name="about_matrix_qr_content_description">QR-Code für Matrix-Kontakt @neso:nesohub.org</string>
<!-- Abspielregeln -->
<string name="playback_rules_section">Abspielregeln</string>
<string name="playback_mode_label">Abspiel-Modus</string>
<string name="playback_mode_every_entry">Bei jedem erneuten Betreten</string>
<string name="playback_mode_once">Nur einmal</string>
<string name="playback_mode_limited">Begrenzt oft</string>
<string name="playback_max_count_label">Maximale Abspielanzahl</string>
<string name="playback_max_count_hint">Anzahl (z. B. 3)</string>
<string name="playback_max_count_error">Muss eine Ganzzahl ≥ 1 sein</string>
<string name="playback_count_info">Bisher abgespielt: %1$d</string>
<string name="playback_count_reset">Zähler zurücksetzen</string>
<string name="schedule_section">Zeitplan</string>
<string name="schedule_enabled_label">Zeitplan aktivieren</string>
<string name="schedule_start_label">Startdatum und -uhrzeit</string>
<string name="schedule_end_label">Enddatum und -uhrzeit</string>
<string name="schedule_pick_start">Start auswählen …</string>
<string name="schedule_pick_end">Ende auswählen …</string>
<string name="schedule_clear_start">Start entfernen</string>
<string name="schedule_clear_end">Ende entfernen</string>
<string name="schedule_daily_label">Tägliches Zeitfenster</string>
<string name="schedule_daily_start_label">Täglicher Beginn</string>
<string name="schedule_daily_end_label">Tägliches Ende</string>
<string name="schedule_pick_daily_start">Beginn auswählen …</string>
<string name="schedule_pick_daily_end">Ende auswählen …</string>
<string name="schedule_clear_window">Zeitfenster entfernen</string>
<string name="schedule_daily_midnight_hint">Tipp: Ein Zeitfenster darf Mitternacht überschreiten (z. B. 22:00 06:00).</string>
<string name="schedule_end_before_start_error">Ende muss nach dem Start liegen</string>
<!-- Playback rule summary (list card) -->
<string name="rule_summary_every_entry">Bei jedem Betreten</string>
<string name="rule_summary_once">Nur einmal</string>
<string name="rule_summary_limited">Max. %1$d×</string>
<string name="rule_summary_played">%1$d gespielt</string>
<string name="rule_summary_schedule_active">Zeitplan aktiv</string>
<!-- Touren / Routen -->
<string name="tour_section_label">Tour</string>
<string name="tour_name_label">Tourname</string>
<string name="tour_name_hint">Vorhandene Tour wählen oder neuen Namen eingeben</string>
<string name="tour_name_empty_error">Tourname darf nicht leer sein</string>
<string name="tour_name_duplicate_error">Eine Tour mit diesem Namen existiert bereits</string>
<string name="tour_new">Neue Tour</string>
<string name="tour_rename">Tour umbenennen</string>
<string name="tour_delete">Tour löschen</string>
<string name="tour_delete_title">Tour löschen?</string>
<string name="tour_delete_msg">Die Tour „%1$s" wird gelöscht. Alle Wegpunkte dieser Tour werden in die Standardtour verschoben.</string>
<string name="tour_delete_standard_hint">Standardtour kann nicht gelöscht werden</string>
<string name="tour_no_waypoints">Diese Tour enthält noch keine Wegpunkte.\nTippe auf +, um einen hinzuzufügen.</string>
<string name="tour_default_name">Standard</string>
<!-- Tour-Vorgaben für neue Wegpunkte -->
<string name="waypoint_inherits_tour_defaults">Abspielregel aus Tour-Vorgabe übernommen. Kann hier überschrieben werden.</string>
<!-- Manuelle Wiedergabe -->
<string name="manual_player_label">Manuelle Wiedergabe</string>
<string name="manual_play">Abspielen</string>
<string name="manual_pause">Pause</string>
<string name="manual_previous">Vorheriger Wegpunkt</string>
<string name="manual_next">Nächster Wegpunkt</string>
<string name="manual_no_audio">Kein Wegpunkt mit Audio verfügbar</string>
<string name="manual_now_playing">Manuell: %1$s</string>
<string name="manual_play_pause">Wiedergabe / Pause</string>
<string name="manual_prev_next_disabled_single">Einzel-Wiedergabe aktiv</string>
<!-- Einzel-Wiedergabe je Wegpunkt (Karten-Schaltfläche) -->
<string name="waypoint_play_single">Diesen Wegpunkt abspielen</string>
<string name="waypoint_pause_single">Diesen Wegpunkt pausieren</string>
<string name="waypoint_no_audio_hint">Keine Audiodatei zugewiesen</string>
<string name="waypoint_single_now_playing">Einzelwiedergabe: %1$s</string>
<string name="waypoint_single_mode_label">Einzelwiedergabe (kein Auto-Weiter)</string>
<!-- Begleitmusik -->
<string name="menu_begleitmusik">Begleitmusik</string>
<string name="music_title">Begleitmusik</string>
<string name="music_for_tour">Tour: %1$s</string>
<string name="music_enable">Begleitmusik aktivieren</string>
<!-- Quelle -->
<string name="music_source_label">Musikquelle</string>
<string name="music_source_local">Lokale Playlist</string>
<string name="music_source_stream">Stream-URL</string>
<!-- Lokale Playlist -->
<string name="music_playlist_label">Playlist</string>
<string name="music_playlist_empty">Noch keine Audiodateien ausgewählt.</string>
<string name="music_playlist_add">Dateien hinzufügen</string>
<string name="music_playlist_clear">Playlist leeren</string>
<string name="music_playlist_remove">Entfernen</string>
<string name="music_shuffle">Zufällige Reihenfolge</string>
<!-- Stream-URL -->
<string name="music_stream_url_label">Stream-URL</string>
<string name="music_stream_url_hint">Direkte http/https-Audio-URL erforderlich (z. B. Internetradio)</string>
<string name="music_stream_url_empty_error">Bitte eine URL eingeben</string>
<string name="music_stream_url_invalid_error">URL muss mit http:// oder https:// beginnen</string>
<string name="music_stream_platform_hint">Hinweis: YouTube, SoundCloud und radio.de geben keine direkt abspielbaren Audio-URLs aus. Bitte eine direkte Stream-URL (z. B. von Internetradio-Anbietern) eingeben. Seiten-Links funktionieren nicht.</string>
<!-- Verhalten beim Wegpunkt -->
<string name="music_behavior_label">Verhalten bei Wegpunkt-Audio</string>
<string name="music_behavior_pause_resume">Pausieren und fortsetzen</string>
<string name="music_behavior_pause_resume_desc">Musik pausiert während des Wegpunkt-Tons und setzt danach fort.</string>
<string name="music_behavior_fade">Fade-out / Fade-in</string>
<string name="music_behavior_fade_desc">Musik wird sanft ausgeblendet, Wegpunkt spielt, danach Einblenden.</string>
<string name="music_behavior_duck">Leise unterlegen (Duck)</string>
<string name="music_behavior_duck_desc">Musik läuft leiser weiter während der Wegpunkt-Ton spielt.</string>
<string name="music_behavior_continue">Normal unterlegen</string>
<string name="music_behavior_continue_desc">Musik läuft auf normaler Lautstärke parallel zum Wegpunkt-Ton.</string>
<!-- Fade und Duck Parameter -->
<string name="music_fade_duration_label">Fade-Dauer: %1$d ms</string>
<string name="music_duck_volume_label">Lautstärke während Wegpunkt: %1$d %%</string>
<!-- Autostart nach Wegpunkt -->
<string name="music_autostart_label">Nach Waypoint automatisch Begleitmusik starten</string>
<string name="music_autostart_desc">Startet die Begleitmusik nach dem Wegpunkt-Ton, auch wenn sie zuvor nicht spielte.</string>
<!-- Test-Steuerung -->
<string name="music_test_label">Vorschau / Test</string>
<string name="music_test_play_pause">Abspielen / Pause</string>
<string name="music_test_stop">Stopp</string>
<!-- Begleitmusik-Karte (Hauptansicht) auf der Hauptseite als "Atmo" angezeigt -->
<string name="music_card_title">Atmo</string>
<string name="waypoint_tracks_heading">Wegpunkt-Tracks</string>
<string name="music_card_disabled">Nicht aktiviert tippe auf Konfigurieren</string>
<string name="music_card_no_source">Keine Quelle konfiguriert</string>
<string name="music_card_local_playlist">Lokale Playlist: %1$d Titel</string>
<string name="music_card_stream">Stream: %1$s</string>
<string name="music_card_configure">Konfigurieren</string>
<string name="music_card_autostart_active">Autostart nach Waypoint aktiv</string>
<string name="music_card_behavior_pause_resume">Pausieren &amp; fortsetzen</string>
<string name="music_card_behavior_fade">Fade-out / Fade-in</string>
<string name="music_card_behavior_duck">Leise unterlegen</string>
<string name="music_card_behavior_continue">Parallel weiterspielen</string>
<!-- Mini-Player (Begleitmusik-Karte) -->
<string name="music_player_live">Live</string>
<string name="music_player_next_label">Als nächstes:</string>
<string name="music_player_previous">Vorheriger Titel</string>
<string name="music_player_next">Nächster Titel</string>
<string name="music_player_stop">Begleitmusik stoppen</string>
<string name="music_player_play_pause">Begleitmusik abspielen / pausieren</string>
<string name="music_player_track_of">%1$d / %2$d</string>
<string name="music_player_no_title">Unbekannter Titel</string>
<string name="music_player_stream_label">Stream</string>
<string name="music_player_idle">Bereit tippe &#9654; zum Starten</string>
<!-- Tour-Zähler-Karte (Hauptansicht) -->
<string name="tour_counter_card_title">Tour-Zähler</string>
<string name="tour_counter_card_configure">Einstellen</string>
<string name="tour_counter_card_played">%1$d× abgespielt</string>
<string name="tour_counter_card_limited">%1$d begrenzt</string>
<string name="tour_counter_card_once">%1$d einmalig</string>
<string name="tour_counter_card_every_entry">%1$d jedes Mal</string>
<string name="tour_counter_card_all_every_entry">Alle Wegpunkte: bei jedem Betreten</string>
<!-- Live / PTT -->
<string name="ptt_card_title">Live / PTT</string>
<string name="ptt_start">PTT starten</string>
<string name="ptt_stop">PTT stoppen</string>
<string name="ptt_status_ready">Bereit</string>
<string name="ptt_status_active">Mikrofon aktiv</string>
<string name="ptt_status_permission_missing">Mikrofon-Berechtigung fehlt</string>
<string name="ptt_status_no_input_device">Kein Eingabegerät gewählt (Systemstandard)</string>
<string name="ptt_status_blocked_by_waypoint">Wegpunkt-Audio läuft</string>
<string name="ptt_permission_rationale">Für die Live/PTT-Funktion wird die Mikrofon-Berechtigung benötigt.</string>
<string name="ptt_permission_denied_hint">Berechtigung verweigert. Bitte in den App-Einstellungen aktivieren.</string>
<string name="ptt_blocked_by_ptt">PTT ist aktiv Wiedergabe wird nach dem PTT-Ende fortgesetzt.</string>
<string name="ptt_echo_warning">⚠ Ohne Headset kann Echo auftreten.</string>
<string name="ptt_audio_devices_button">Audiogeräte</string>
<string name="ptt_hint">Tippe &#9654; zum Starten Mikrofon wird direkt übertragen</string>
<string name="ptt_input_label">Eingabe</string>
<string name="ptt_output_label">Ausgabe</string>
<string name="ptt_device_system_default">Systemstandard</string>
<!-- Audio-Geräte-Dialog -->
<string name="audio_devices_dialog_title">Audiogeräte auswählen</string>
<string name="audio_input_device_label">Eingabegerät (Mikrofon)</string>
<string name="audio_output_device_label">Ausgabegerät (Lautsprecher)</string>
<string name="audio_devices_refresh">Geräte aktualisieren</string>
<string name="audio_devices_save">Speichern</string>
<string name="audio_devices_hint">Geräte-IDs können sich nach Neustart oder Trennen/Verbinden ändern. Bei nicht verfügbarem Gerät wird automatisch der Systemstandard verwendet.</string>
<string name="audio_devices_no_input">Keine Eingabegeräte verfügbar</string>
<string name="audio_devices_no_output">Keine Ausgabegeräte verfügbar</string>
<string name="audio_device_bluetooth">Bluetooth</string>
<string name="audio_device_usb">USB</string>
<string name="audio_device_internal_mic">Internes Mikrofon</string>
<string name="audio_device_speaker">Lautsprecher</string>
<!-- Tour-Zähler-Dialog -->
<string name="tour_counter_dialog_title">Zähler je Tour</string>
<string name="tour_counter_waypoints">%1$d Wegpunkt(e) in dieser Tour</string>
<string name="tour_counter_total_played">Gesamt abgespielt: %1$d</string>
<string name="tour_counter_reset_section">Zähler zurücksetzen</string>
<string name="tour_counter_reset_button">Alle Zähler dieser Tour zurücksetzen</string>
<string name="tour_counter_mode_section">Abspiel-Modus für alle Wegpunkte setzen</string>
<string name="tour_counter_mode_label">Abspiel-Modus</string>
<string name="tour_counter_reset_with_apply">Zähler beim Anwenden zurücksetzen</string>
<string name="tour_counter_apply_button">Auf alle Wegpunkte anwenden</string>
<string name="tour_counter_apply_hint">Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe.</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WaypointAudioGuide" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="." />
<files-path name="internal_files" path="." />
</paths>