From b2aa1c167cf2c097e97629fc7032400a5ce0a1b9 Mon Sep 17 00:00:00 2001 From: Marcel Mayer Date: Tue, 5 May 2026 20:54:05 +0200 Subject: [PATCH] Initial release of GPS2Audio --- .gitignore | 11 + LICENSE | 193 ++ README.md | 1114 ++++++++++++ app/build.gradle.kts | 64 + app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 91 + .../kotlin/de/waypointaudio/MainActivity.kt | 172 ++ .../kotlin/de/waypointaudio/WaypointApp.kt | 29 + .../data/AudioRoutingSettings.kt | 67 + .../de/waypointaudio/data/GpsTrackPoint.kt | 14 + .../waypointaudio/data/TourAudioSettings.kt | 60 + .../de/waypointaudio/data/TourMusicStore.kt | 122 ++ .../data/TourPlaybackDefaults.kt | 15 + .../kotlin/de/waypointaudio/data/Waypoint.kt | 70 + .../de/waypointaudio/data/WaypointStore.kt | 254 +++ .../waypointaudio/io/ImportExportManager.kt | 407 +++++ .../repository/WaypointRepository.kt | 76 + .../service/AudioDeviceManager.kt | 145 ++ .../de/waypointaudio/service/AudioPlayer.kt | 110 ++ .../service/BackgroundMusicManager.kt | 187 ++ .../service/BackgroundMusicPlayer.kt | 513 ++++++ .../waypointaudio/service/LivePttManager.kt | 220 +++ .../waypointaudio/service/LivePttService.kt | 275 +++ .../service/ManualAudioPlayer.kt | 239 +++ .../service/TrackRecordingManager.kt | 98 ++ .../service/TrackRecordingService.kt | 205 +++ .../service/WaypointLocationService.kt | 458 +++++ .../kotlin/de/waypointaudio/ui/AboutDialog.kt | 198 +++ .../de/waypointaudio/ui/BegleitmusikDialog.kt | 483 +++++ .../kotlin/de/waypointaudio/ui/LivePttCard.kt | 476 +++++ .../kotlin/de/waypointaudio/ui/MapScreen.kt | 903 ++++++++++ .../de/waypointaudio/ui/PermissionScreen.kt | 84 + .../de/waypointaudio/ui/TourCounterDialog.kt | 270 +++ .../de/waypointaudio/ui/WaypointEditDialog.kt | 772 ++++++++ .../de/waypointaudio/ui/WaypointListScreen.kt | 1560 +++++++++++++++++ .../kotlin/de/waypointaudio/ui/theme/Theme.kt | 84 + .../viewmodel/WaypointViewModel.kt | 1042 +++++++++++ .../res/drawable-nodpi/matrix_contact_qr.png | Bin 0 -> 21106 bytes app/src/main/res/drawable/ic_launcher.xml | 57 + .../main/res/drawable/ic_my_location_puck.xml | 36 + app/src/main/res/values/strings.xml | 310 ++++ app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/file_paths.xml | 5 + build.gradle.kts | 6 + gradle.properties | 5 + gradle/libs.versions.toml | 42 + gradle/wrapper/gradle-wrapper.properties | 5 + settings.gradle.kts | 17 + 48 files changed, 11570 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/de/waypointaudio/MainActivity.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/WaypointApp.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/GpsTrackPoint.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/TourPlaybackDefaults.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/io/ImportExportManager.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/AudioDeviceManager.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicManager.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/LivePttService.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/TrackRecordingManager.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/TrackRecordingService.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/PermissionScreen.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/TourCounterDialog.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/ui/theme/Theme.kt create mode 100644 app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt create mode 100644 app/src/main/res/drawable-nodpi/matrix_contact_qr.png create mode 100644 app/src/main/res/drawable/ic_launcher.xml create mode 100644 app/src/main/res/drawable/ic_my_location_puck.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c943ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +app/build/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9935400 --- /dev/null +++ b/LICENSE @@ -0,0 +1,193 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and derivative works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and subsequently + incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a cross-claim + or counterclaim in a lawsuit) alleging that the Work or any + Contribution embodied within the Work constitutes direct or + contributory patent infringement, then any patent licenses granted to + You under this License for that Work shall terminate as of the date + such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the attribution + notices contained within such NOTICE file, in at least one of + the following places: within a NOTICE text file distributed as + part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and do + not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an + addendum to the NOTICE text from the Work, provided that such + additional attribution notices cannot be construed as modifying + the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Work, + and to permit persons to whom the Work is furnished to do so, + subject to the following conditions. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. An additional + intellectual property notice may also be required by applicable law. + + Copyright 2026 Marcel Mayer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eebc1ce --- /dev/null +++ b/README.md @@ -0,0 +1,1114 @@ +# GPS2Audio + +Eine native Android-App (Kotlin + Jetpack Compose), die GPS-Wegpunkte überwacht und beim Betreten des jeweiligen Radius automatisch eine Audiodatei abspielt. Wegpunkte können zusätzlich **manuell** ohne aktives GPS über die integrierte Wiedergabeleiste abgespielt werden. + +--- + +## Funktionen + +- **Wegpunkte anlegen, bearbeiten, löschen** mit Name, Koordinaten (Lat/Lng), Radius (Meter) und zugeordneter Audiodatei +- **Automatische Audioauslösung**, sobald das Gerät in den Radius eines Wegpunkts eintritt +- **Abspielregeln & Zeitplan** pro Wegpunkt – flexibel steuerbar: bei jedem Betreten, nur einmal, begrenzt oft, mit optionalem Datum/Uhrzeit-Fenster und täglichem Zeitfenster +- **Hintergrundbetrieb** via Vordergrund-Dienst (`ForegroundService`) mit dauerhafter Benachrichtigung +- **Lokale Datenspeicherung** via Jetpack DataStore + JSON (kein Backend, kein Internet erforderlich) +- **Manuelle Wiedergabe** – Waypoints ohne GPS abspielen: Zurück, Play/Pause, Weiter direkt in der Übersicht +- **Material Design 3** Oberfläche mit professionellem Navy/Teal-Farbschema, Dark-Mode-fähig +- **JSON/GPX Import & Export** – Wegpunkte als Datei teilen, sichern oder übertragen (inkl. Abspielregel-Felder) +- **Kartenansicht** – OpenStreetMap/osmdroid mit Markern und Radius-Kreisen +- **Wegpunkt aus GPS-Position** – aktuellen Standort per Knopfdruck als neuen Wegpunkt übernehmen + +--- + +## Voraussetzungen + +| Anforderung | Version | +|---------------------------|-------------------| +| Android Studio | Hedgehog 2023.1.1+ | +| Android SDK | API 26 (Android 8.0) Minimum, API 35 Target | +| Kotlin | 2.0.21 | +| Gradle | 8.9 | +| Google Play Services | Gerät mit GMS (FusedLocationProvider) | + +--- + +## Build & Run + +### Android Studio + +1. **Projekt öffnen**: `File → Open → .../waypoint-audio-guide` +2. **Gradle synchronisieren**: Android Studio führt dies automatisch beim Öffnen durch (`File → Sync Project with Gradle Files`) +3. **Gerät/Emulator verbinden**: Physisches Gerät empfohlen für GPS-Tests +4. **App starten**: Grüner Play-Button oder `Shift+F10` + +### Kommandozeile (erfordert lokales Android SDK) + +```bash +# ANDROID_HOME muss auf das SDK-Verzeichnis zeigen +export ANDROID_HOME=/path/to/android-sdk + +cd waypoint-audio-guide +./gradlew assembleDebug + +# APK befindet sich unter: +# app/build/outputs/apk/debug/app-debug.apk +``` + +### APK auf Gerät installieren + +```bash +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +--- + +## Berechtigungen + +Die App fordert folgende Berechtigungen an: + +| Berechtigung | Zweck | +|---|---| +| `ACCESS_FINE_LOCATION` | Genaue GPS-Position für Wegpunkt-Erkennung und aktuelle Position | +| `ACCESS_COARSE_LOCATION` | Grobe Positionsbestimmung (Fallback) | +| `ACCESS_BACKGROUND_LOCATION` | Wegpunkt-Erkennung bei geschlossener App (Android 10+) | +| `FOREGROUND_SERVICE` | Vordergrund-Dienst für dauerhaften GPS-Empfang | +| `FOREGROUND_SERVICE_LOCATION` | Standorttyp für Vordergrund-Dienst (Android 14+) | +| `POST_NOTIFICATIONS` | Dauerhafte Benachrichtigung während des Dienstbetriebs (Android 13+) | +| `READ_MEDIA_AUDIO` | Zugriff auf Audiodateien aus dem Medienspeicher (Android 13+) | +| `READ_EXTERNAL_STORAGE` | Zugriff auf Audiodateien (Android ≤ 12) | +| `WAKE_LOCK` | CPU aktiv halten während der Audiowiedergabe | +| `INTERNET` | Kartenkacheln von OpenStreetMap laden (osmdroid) | +| `ACCESS_NETWORK_STATE` | Netzwerkstatus für Kachel-Cache-Entscheidungen | +| `RECORD_AUDIO` | Mikrofon für Live/PTT-Funktion | +| `FOREGROUND_SERVICE_MICROPHONE` | Vordergrund-Dienst mit Mikrofon-Typ (Android 14+) | + +### Wichtige Hinweise zu Mikrofon-Berechtigung (Live / PTT) + +Die `RECORD_AUDIO`-Berechtigung wird **erst beim ersten Tippen des PTT-Buttons** angefordert (Laufzeit-Permission). Wenn die Berechtigung verweigert wird, erscheint ein Hinweis in der PTT-Karte; der Rest der App funktioniert uneingeschränkt weiter. + +Die Berechtigung kann jederzeit in den **App-Einstellungen → Berechtigungen → Mikrofon** nachträglich erteilt werden. + +### Wichtige Hinweise zu Hintergrundstandort + +Ab **Android 10 (API 29)** muss `ACCESS_BACKGROUND_LOCATION` **separat** nach der Gewährung von `ACCESS_FINE_LOCATION` angefragt werden. Android leitet den Benutzer dazu automatisch zu den App-Einstellungen. Dort muss „Immer" (nicht nur „Bei Nutzung der App") gewählt werden. + +Ab **Android 11 (API 30)** wird der Hintergrundstandort **nicht** mehr im Systemdialog genehmigt – der Benutzer muss explizit die App-Einstellungen öffnen. + +--- + +## Bedienung + +### Grundfunktionen + +1. **App starten** → Berechtigungen erlauben +2. **Wegpunkt anlegen**: Auf `+`-Button tippen → Name, Koordinaten, Radius und Audiodatei eingeben → Speichern +3. **Koordinaten ermitteln**: Latitude/Longitude in Dezimalgrad eingeben (z. B. `48.137154` / `11.576124` für München Marienplatz) +4. **Audiodatei wählen**: Auf „Audiodatei auswählen" tippen → Datei-Manager öffnet sich → MP3/WAV/OGG auswählen +5. **Dienst starten**: Auf ▶-Button in der Titelleiste tippen → grüne Statusleiste unten erscheint +6. **Hintergrund**: App kann geschlossen werden; der Dienst läuft weiter und zeigt eine Benachrichtigung +7. **Wegpunkt betreten**: Wenn Gerät in den eingestellten Radius kommt → Ton wird abgespielt, Benachrichtigung aktualisiert +8. **Dienst stoppen**: Auf ■-Button tippen oder App-Benachrichtigung entfernen + +### Wegpunkt aus aktuellem GPS-Standort erstellen + +1. Auf den **📍-Button** (kleiner FAB) neben dem +-Button tippen +2. Die App ermittelt die aktuelle GPS-Position via FusedLocationProviderClient +3. Der Bearbeitungsdialog öffnet sich mit vorausgefüllten Koordinaten +4. Name, Radius und Audiodatei eingeben → Speichern + +**Hinweis**: Dazu muss `ACCESS_FINE_LOCATION` erteilt sein und GPS aktiviert sein. Ist kein frischer Standort verfügbar, wird auf `lastKnownLocation` zurückgegriffen. Fehlt auch dieser, erscheint eine Fehlermeldung. + +### JSON Import & Export + +Import und Export erfolgen über das **Overflow-Menü (⋮)** in der Titelleiste. + +#### Export (JSON) +- Menü → „Als JSON exportieren" +- Systemdateiauswahl öffnet sich → Speicherort und Dateiname wählen +- Alle Wegpunkte werden als JSON-Array gespeichert, inkl. ID, Name, Koordinaten, Radius und Sound-Name + +#### Import (JSON) +- Menü → „JSON importieren" +- Systemdateiauswahl öffnet sich → JSON-Datei auswählen +- **Achtung**: Die bestehende Wegpunktliste wird durch die importierten Daten **ersetzt** +- Audiodatei-URIs werden mitimportiert, sind aber gerätegebunden (content:// URIs) + +**JSON-Format:** +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Marienplatz München", + "latitude": 48.137154, + "longitude": 11.576124, + "radiusMeters": 50.0, + "soundUri": "", + "soundName": "", + "isActive": true + } +] +``` + +### GPX Import & Export + +#### Export (GPX 1.1) +- Menü → „Als GPX exportieren" +- Systemdateiauswahl öffnet sich → Speicherort und Dateiname wählen +- Erzeugt valides GPX 1.1 mit ``, ``, `` und `` + +**GPX-Struktur:** +```xml + + Marienplatz München + Radius: 50m + + 550e8400-... + 50.0 + true + + +``` + +#### Import (GPX) +- Menü → „GPX importieren" +- GPX-Dateien anderer Apps werden unterstützt; fehlende Felder erhalten Standardwerte: + - **Radius**: 50 m (wenn kein `` vorhanden) + - **Sound**: keiner (Audiodatei-URIs sind nicht übertragbar) + - **Name**: „Wegpunkt N" (wenn kein `` vorhanden) +- ``-Erweiterungen ermöglichen verlustfreien Round-Trip (Export → Import in dieselbe App) +- **Achtung**: Die bestehende Wegpunktliste wird **ersetzt** + +### Kartenansicht + +1. Auf das **Karten-Symbol** (🗺) in der Titelleiste tippen +2. OpenStreetMap-Karte lädt mit allen Wegpunkten als Marker +3. Radius jedes Wegpunkts wird als halbtransparenter Kreis angezeigt +4. Inaktive Wegpunkte erscheinen transparent +5. Auf den **Standort-FAB** (MyLocation-Symbol, rechts unten) tippen → Karte zentriert auf den aktuellen GPS-Standort; ein moderner blauer Standort-Puck erscheint +6. Nach Positionsermittlung erscheint oben ein Koordinatenband und der Button **„Als Wegpunkt übernehmen“** (erweiterter FAB links vom Standort-FAB) +7. **„Als Wegpunkt übernehmen“** antippen → Wegpunkt-Dialog mit vorausgefüllten Koordinaten → Name, Radius und Audiodatei eingeben → Speichern +8. Zurück-Pfeil → zurück zur Liste + +**Kurzanleitung – Wegpunkt aus Kartenposition erstellen:** +1. Karte öffnen (🗺-Symbol) +2. Standort-FAB (MyLocation) antippen → Karte zentriert auf aktuelle Position +3. **„Als Wegpunkt übernehmen“** antippen → Dialog mit Koordinaten öffnet sich +4. Name, Radius (Meter) und optional eine Audiodatei eingeben → „Speichern“ + +**Hinweis zu Internet und Kacheln**: Die Karte benötigt eine Internetverbindung für das erstmalige Laden der Kartenkacheln. Geladene Kacheln werden im App-Cache gespeichert und sind anschließend offline verfügbar. Attribution: Kartendaten © OpenStreetMap-Mitwirkende (ODbL), Kacheln © OpenStreetMap (CC BY-SA). + +--- + +## Abhängigkeiten (neue) + +| Bibliothek | Version | Zweck | +|---|---|---| +| osmdroid-android | 6.1.20 | OpenStreetMap-Kartendarstellung | +| media3-exoplayer | 1.4.1 | Begleitmusik: lokale Playlists und HTTP(S)-Streams | +| media3-common | 1.4.1 | Media3-Basisklassen (MediaItem, Player) | + +Die Karte wird über `AndroidView` in Compose eingebettet. osmdroid verwaltet den Kachel-Cache selbstständig unter `context.cacheDir/osmdroid`. + +--- + +## Projektstruktur + +``` +waypoint-audio-guide/ +├── app/ +│ ├── src/main/ +│ │ ├── AndroidManifest.xml +│ │ ├── kotlin/de/waypointaudio/ +│ │ │ ├── MainActivity.kt # Einstiegspunkt, Berechtigungsmanagement, NavHost +│ │ │ ├── WaypointApp.kt # Application-Klasse, Notification Channel +│ │ │ ├── data/ +│ │ │ │ ├── Waypoint.kt # Datenmodell +│ │ │ │ └── WaypointStore.kt # DataStore + JSON-Persistenz +│ │ │ ├── io/ +│ │ │ │ └── ImportExportManager.kt # JSON & GPX Import/Export [NEU] +│ │ │ ├── repository/ +│ │ │ │ └── WaypointRepository.kt # Repository-Abstraktion +│ │ │ ├── viewmodel/ +│ │ │ │ └── WaypointViewModel.kt # UI-State, Dienst-Steuerung, Import/Export, GPS +│ │ │ ├── service/ +│ │ │ │ ├── WaypointLocationService.kt # Vordergrund-Dienst, GPS-Loop +│ │ │ │ ├── AudioPlayer.kt # MediaPlayer-Wrapper (GPS-Dienst) +│ │ │ │ └── ManualAudioPlayer.kt # Separater Player für manuelle Wiedergabe [NEU] +│ │ │ └── ui/ +│ │ │ ├── theme/Theme.kt # Material 3 Farbpalette +│ │ │ ├── WaypointListScreen.kt # Hauptbildschirm (Liste + Overflow-Menü) +│ │ │ ├── WaypointEditDialog.kt # Anlegen/Bearbeiten-Dialog (GPS-Vorausfüllung) +│ │ │ ├── MapScreen.kt # Kartenansicht (osmdroid) [NEU] +│ │ │ └── PermissionScreen.kt # Fehlende Berechtigungen +│ │ └── res/ +│ │ ├── values/strings.xml # Alle UI-Texte (Deutsch, erweitert) +│ │ ├── values/themes.xml +│ │ └── xml/file_paths.xml # FileProvider-Konfiguration +│ └── build.gradle.kts +├── gradle/ +│ ├── libs.versions.toml # Version Catalog (osmdroid ergänzt) +│ └── wrapper/gradle-wrapper.properties +├── build.gradle.kts +├── settings.gradle.kts +└── README.md +``` + +--- + +## Architektur + +``` +UI (Compose) + ├── WaypointListScreen (Liste, Overflow-Menü, GPS-FAB) + │ ↕ + │ WaypointEditDialog (Anlegen/Bearbeiten, GPS-Vorausfüllung) + └── MapScreen (osmdroid MapView via AndroidView) + ↕ +WaypointViewModel (AndroidViewModel, StateFlow) + ├── importJson / exportJson / importGpx / exportGpx → ImportExportManager + ├── fetchCurrentLocation → FusedLocationProviderClient + └── startService / stopService + ↕ +WaypointRepository + ↕ +WaypointStore (DataStore Preferences + Gson/JSON) + +WaypointLocationService (ForegroundService) + ├── FusedLocationProviderClient (GPS-Updates alle 5 s) + ├── WaypointRepository (liest Wegpunkte direkt) + └── AudioPlayer (MediaPlayer – GPS-triggered) + +ManualAudioPlayer (ViewModel-owned, UI-driven) + ├── play / pause / stop / release + └── Unterstützt Pause (Position wird gehalten) + Resume + +ImportExportManager (Singleton object) + ├── toJson / fromJson (Gson) + └── toGpx / fromGpx (XmlPullParser / XmlSerializer) +``` + +--- + +## Abspielregeln & Zeitplan + +Jeder Wegpunkt kann mit individuellen Abspielregeln konfiguriert werden. Die Einstellungen sind vollständig optional – fehlende Felder entsprechen dem bisherigen Standardverhalten (Ton bei jedem Betreten). + +### Abspiel-Modi + +| Modus | Beschreibung | +|---|---| +| **Bei jedem erneuten Betreten** (Standard) | Ton wird bei jeder neuen Eintrittserkennung abgespielt. Entspricht dem bisherigen Verhalten. | +| **Nur einmal** | Ton wird höchstens einmal abgespielt (wenn `playCount == 0`). Danach bleibt der Wegpunkt aktiv, aber stumm. | +| **Begrenzt oft** | Ton wird maximal N-mal abgespielt. Die Anzahl wird im Feld „Maximale Abspielanzahl" eingestellt. | + +### Zeitplan + +Zusätzlich zum Modus kann ein optionaler Zeitplan aktiviert werden. Alle Zeitplan-Felder sind unabhängig voneinander kombinierbar: + +| Feld | Auswahl | Beschreibung | +|---|---|---| +| Start-Datum/Zeit | DatePicker + TimePicker | Frühestes Datum/Uhrzeit, ab dem Abspielen erlaubt ist | +| Ende-Datum/Zeit | DatePicker + TimePicker | Spätestes Datum/Uhrzeit, bis zu dem Abspielen erlaubt ist | +| Täglicher Beginn | TimePicker | Uhrzeit, ab der das tägliche Zeitfenster beginnt | +| Tägliches Ende | TimePicker | Uhrzeit, bis zu der das tägliche Zeitfenster endet | + +Datum und Uhrzeit werden über die nativen Android-Dialoge (DatePickerDialog / TimePickerDialog) gewählt – keine manuelle Texteingabe erforderlich. Gewählte Werte werden im Format `TT.MM.JJJJ HH:mm` bzw. `HH:mm` angezeigt. Jedes Feld kann über die Schaltflächen „Start entfernen", „Ende entfernen" oder „Zeitfenster entfernen" wieder geleert werden. + +**Logik bei kombinierten Feldern:** +- Alle gesetzten Bedingungen müssen gleichzeitig erfüllt sein (UND-Verknüpfung). +- Nicht gesetzte Felder werden ignoriert (keine Einschränkung). +- Tägliche Zeitfenster, die über Mitternacht gehen (z. B. 22:00–06:00), werden korrekt unterstützt. + +### Beispiele + +**Nur einmal abspielen:** +- Modus: „Nur einmal" +- Kein Zeitplan nötig +- Ergebnis: Ton ertönt beim ersten Betreten, danach nie wieder + +**Während einer Veranstaltung abspielen (Datum-Fenster):** +- Modus: „Bei jedem erneuten Betreten" +- Zeitplan aktivieren +- Start per DatePicker + TimePicker wählen: 01.06.2025 09:00, Ende: 01.06.2025 18:00 +- Ergebnis: Ton ertönt nur am 1. Juni 2025 zwischen 9 und 18 Uhr + +**Werktags morgens abspielen (tägliches Fenster):** +- Modus: „Bei jedem erneuten Betreten" +- Zeitplan aktivieren +- Täglicher Beginn per TimePicker wählen: 07:00, Tägliches Ende: 09:00 +- Ergebnis: Ton ertönt täglich nur zwischen 7 und 9 Uhr (unabhängig vom Datum) + +**Begrenzte Anzahl + Nächtliches Zeitfenster:** +- Modus: „Begrenzt oft", maximale Anzahl: 3 +- Täglicher Beginn per TimePicker: 22:00, Tägliches Ende: 06:00 (Mitternachts-übergreifend) +- Ergebnis: Ton ertönt nachts bis zu 3-mal insgesamt + +### Abspielzähler + +Der „bisher abgespielt"-Zähler (`playCount`) wird nach jeder erfolgreichen Wiedergabe automatisch erhöht und persistent gespeichert. Im Bearbeitungsdialog kann der Zähler per Knopf „Zähler zurücksetzen" auf 0 gesetzt werden. Beim Wechsel des Modus bleibt der Zähler erhalten (wird nicht automatisch zurückgesetzt), damit historische Daten nicht verloren gehen. + +### Darstellung in der Liste + +Jede Wegpunktkarte in der Liste zeigt eine kompakte Zusammenfassung der Abspielregel, z. B.: +- `Abspielen: nur einmal · 0/1 gespielt` +- `Abspielen: max. 3× · 1/3 gespielt` +- `Abspielen: bei jedem Betreten · Zeitplan aktiv` + +### JSON/GPX-Kompatibilität + +- **JSON-Export/-Import**: Alle neuen Felder werden automatisch serialisiert. Ältere JSON-Dateien ohne diese Felder werden durch Kotlin-Standardwerte korrekt ergänzt (backward-kompatibel). +- **GPX-Export/-Import**: Alle Abspielregel-Felder werden als ``-Erweiterungen in `` gespeichert (Round-Trip-fähig). Andere GPX-Apps ignorieren diese Erweiterungen. Beim GPX-Import fehlt die `soundUri` immer (content:// URIs sind gerätegebunden); die Audiodatei muss neu zugeordnet werden. + +### Einschränkungen + +- Zeitplan-Bedingungen werden nur beim **Eintrittsereignis** geprüft, nicht während des Aufenthalts im Radius. +- Alle Zeitangaben werden in Geräte-Ortszeit interpretiert (keine Zeitzonenauswahl möglich). +- Der Zähler `playCount` bleibt beim Löschen und Neuanlegen desselben Wegpunkts (mit identischer UUID) erhalten, da er Bestandteil der JSON-Persistenz ist. + +--- + +## Einschränkungen + +- **GMS-Abhängigkeit**: `FusedLocationProviderClient` erfordert Google Play Services. Auf AOSP/GrapheneOS-Geräten ohne GMS müsste `LocationManager` als Fallback implementiert werden. +- **Karteninternet**: Die osmdroid-Karte benötigt beim ersten Start eine Internetverbindung zum Laden der Kacheln. Danach sind gecachte Kacheln offline verfügbar. +- **OpenStreetMap-Attribution**: Pflichtgemäß muss die Karte mit „© OpenStreetMap-Mitwirkende" beschriftet sein (ODbL-Lizenz). Die App zeigt dies im `CompassOverlay` und im README. +- **Audiodatei-URIs beim Import**: `content://`-URIs sind gerätegebunden und können nicht zwischen Geräten übertragen werden. Beim GPX/JSON-Import auf einem anderen Gerät muss die Audiodatei neu zugeordnet werden. +- **GPX-Sound**: Der GPX-Standard hat kein natives Feld für Audiodateien. Die App nutzt den eigenen Namespace `` in ``. Andere GPX-Apps ignorieren dieses Feld. +- **Einmaliger Auslöser**: Der Ton wird nur einmal pro Eintritt in den Radius abgespielt (nicht wiederholt, solange man im Radius bleibt). +- **Hintergrundstandort**: Auf Android 11+ muss der Benutzer die Berechtigung manuell in den App-Einstellungen erteilen. +- **Akku-Optimierung**: Aggressive Akku-Optimierung des Herstellers (Huawei EMUI, Xiaomi MIUI) kann den Dienst trotz Vordergrundstatus beenden. +- **Audiodateiberechtigungen**: Die App sichert `persistableUriPermission` für ausgewählte Audiodateien. Nach einem Neustart des Geräts kann in seltenen Fällen der Systemdateimanager die URI-Berechtigung widerrufen. +- **Gradle-Build im CI ohne SDK**: Kann ohne installiertes Android SDK nicht kompiliert werden. Der Quellcode ist vollständig; ein lokales Android Studio Build ist erforderlich. +- **Kartenradius-Kreis**: Der Radius-Kreis auf der Karte ist eine Polygon-Annäherung (64-Eck), keine exakte geodätische Berechnung. Bei kleinen Radien (< 500 m) ist der Unterschied vernachlässigbar. + +--- + +## Technologien + +| Technologie | Verwendung | +|---|---| +| Kotlin 2.0 | Primäre Sprache | +| Jetpack Compose | UI-Framework | +| Material Design 3 | Design-System | +| Jetpack DataStore | Lokale Datenpersistenz | +| Gson | JSON-Serialisierung | +| FusedLocationProviderClient | GPS-Standortabrufe | +| Android ForegroundService | Hintergrundbetrieb | +| MediaPlayer | Wegpunkt-Audiowiedergabe (Service) | +| ExoPlayer / AndroidX Media3 1.4.1 | Begleitmusik (lokale Playlist + HTTP-Streams) | +| Kotlin Coroutines + Flow | Reaktive Datenschicht | +| ViewModel + AndroidViewModel | UI-State-Management | +| Navigation Compose | In-App-Navigation (Liste ↔ Karte) | +| osmdroid 6.1.20 | OpenStreetMap-Kartenansicht | +| XmlPullParser / XmlSerializer | GPX 1.1 Parsing und Erzeugung | + +--- + +## Entwickler + +| | | +|---|---| +| **App-Name** | GPS2Audio | +| **Name** | Marcel Mayer | +| **E-Mail** | marcel.mayer@nesohub.org | +| **Matrix** | [@neso:nesohub.org](https://matrix.to/#/@neso:nesohub.org) – QR-Code & direkter Link im Info-Dialog | + +### Matrix-Kontakt + +Die Info-Seite der App enthält sowohl einen **QR-Code** als auch einen **klickbaren Button „Matrix-Chat öffnen"** für die direkte Kontaktaufnahme über Matrix. + +- **Matrix-ID:** `@neso:nesohub.org` +- **Direktlink:** [https://matrix.to/#/@neso:nesohub.org](https://matrix.to/#/@neso:nesohub.org) + +Den Abschnitt erreicht man über das Overflow-Menü (⋮) → „Über diese App" → Abschnitt „Matrix-Kontakt". Ein Tipp auf den Button öffnet den Matrix-Chat direkt in einer installierten Matrix-App oder im Browser. + +--- + +## Lizenz + +Dieses Projekt steht unter der **Apache License 2.0**. + +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. + +Vollständiger Lizenztext: [https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0) + +Siehe auch die Datei [LICENSE](./LICENSE) im Projektverzeichnis. + +Copyright 2026 Marcel Mayer + +Kartendaten © [OpenStreetMap](https://www.openstreetmap.org/copyright)-Mitwirkende, verfügbar unter der Open Database Licence (ODbL). + +--- + +## Fehlerbehebung (Troubleshooting) + +### GPS-Button funktioniert nicht / „Position kann nicht bestimmt werden" + +**Symptom:** Der 📍-Button (kleiner FAB) reagiert nicht oder es erscheint eine Fehlermeldung. + +#### Checkliste + +1. **Standortberechtigung erteilen** + - Einstellungen → Apps → GPS2Audio → Berechtigungen → Standort + - Wählen Sie **„Genauer Standort"** (ACCESS_FINE_LOCATION) oder mindestens **„Ungefährer Standort"** + - **Nicht** „Verweigern" – ohne Standortberechtigung kann die Position nicht ermittelt werden + +2. **GPS-Dienst aktivieren** + - Schnelleinstellungen (von oben wischen) → Standort-Symbol einschalten + - **oder:** Einstellungen → Standort → Standort aktivieren + - Modus: **„Hohe Genauigkeit"** (GPS + WLAN + Mobilfunk) empfohlen + +3. **Im Freien testen** + - GPS-Signale werden in Gebäuden oft nicht empfangen + - Beim ersten Start kann es 30–60 Sekunden dauern, bis ein GPS-Fix vorhanden ist + - Im Freien mit freier Sicht zum Himmel ist die Positionsermittlung am schnellsten + +4. **Akku-Optimierung deaktivieren** (bei ausbleibender Reaktion) + - Einstellungen → Apps → GPS2Audio → Akku → **„Nicht optimieren"** oder **„Unbeschränkt"** + - Hersteller-spezifisch: Huawei (EMUI), Xiaomi (MIUI), Samsung (One UI) schränken GPS im Hintergrund aggressiv ein + +5. **Android-Standorteinstellungen prüfen** + - Einstellungen → Standort → App-Berechtigungen → GPS2Audio → **„Bei Nutzung der App"** auswählen + - Für Hintergrundbetrieb (Dienst): **„Immer"** auswählen (Android 10+) + +6. **Mock-Standort im Emulator setzen** (für Entwickler/Tests) + - Android Emulator: Extended Controls (…) → Location → Koordinaten eingeben → „Send" + - z. B. München: Latitude `48.137154`, Longitude `11.576124` + - Oder per `adb shell geo fix ` + +#### Fehlermeldungen und Bedeutung + +| Fehlermeldung | Ursache | Lösung | +|---|---|---| +| „Standortberechtigung fehlt" | App hat keine Location-Berechtigung | Berechtigung in App-Einstellungen erteilen | +| „GPS ist deaktiviert" | Standortdienst im System ausgeschaltet | GPS in Systemeinstellungen aktivieren | +| „GPS-Position konnte nicht ermittelt werden" | Kein GPS-Empfang (Innenbereich) | Im Freien versuchen, warten bis GPS-Fix | +| „Standortfehler: …" | Google Play Services nicht verfügbar | Gerät mit Google Play Services verwenden | + +--- + +### Karte wird angezeigt, aber Position ist unbekannt + +- Der Karten-FAB (📍 rechts unten) holt den aktuellen Standort und zentriert die Karte +- Ohne GPS-Berechtigung → Karte zeigt den ersten Wegpunkt (oder Deutschlandmitte) +- Stellen Sie sicher, dass Standortberechtigung vorhanden und GPS aktiviert ist + +--- + +### Koordinaten-Dezimaltrennzeichen: Komma statt Punkt + +**Symptom:** Beim Öffnen des Wegpunkt-Dialogs via GPS-Button oder Kartenansicht erscheinen die vorausgefüllten Koordinaten mit Komma als Dezimaltrennzeichen (z. B. `48,137154`), und das Speichern schlägt fehl. + +**Ursache:** Auf deutschsprachigen Geräten kann die Standard-Gebietsschema-Formatierung des Android-Systems Kommas anstelle von Punkten als Dezimaltrennzeichen in Gleitkommazahlen verwenden. + +**Lösung (ab dieser Version behoben):** Die App formatiert GPS-Koordinaten jetzt immer mit `Locale.US` (Punkt als Dezimaltrennzeichen, z. B. `48.1371540`). Außerdem werden beim Speichern Kommas in den Eingabefeldern automatisch durch Punkte ersetzt, sodass manuelle Eingaben im deutschen Format (z. B. `52,123456`) ebenfalls akzeptiert werden. + +**Koordinaten-Eingabeformat:** +- Empfohlen: Punkt als Dezimaltrennzeichen, z. B. `48.137154` / `11.576124` +- Alternativ: Komma wird ebenfalls akzeptiert, z. B. `48,137154` / `11,576124` + +--- + +### Berechtigungen wurden versehentlich verweigert + +1. Einstellungen → Apps → GPS2Audio → Berechtigungen +2. Standort → **„Genauer Standort"** aktivieren +3. Benachrichtigungen → aktivieren (für Dienst-Benachrichtigung) +4. Mediendateien/Audio → aktivieren (für Audiodatei-Auswahl) +5. App neu starten + +--- + +### Hintergrund-Dienst erkennt keine Wegpunkte + +- Prüfen Sie, ob der Dienst gestartet ist (▶-Button in der Titelleiste → grüne Statusleiste) +- Standortberechtigung muss **„Immer"** sein (nicht nur „Bei Nutzung") +- Akku-Optimierung für die App deaktivieren +- Gerät nicht in den Flugmodus versetzen + +--- + +## Touren / Routen + +### Überblick + +Wegpunkte können in **Touren** (Routen) organisiert werden. Jede Tour erscheint als **Tab/Reiter** am oberen Rand der Hauptliste. Die Kartenansicht zeigt ebenfalls nur die Wegpunkte der aktuell gewählten Tour. + +### Neue Tour erstellen + +1. Overflow-Menü **(⋮)** in der Titelleiste öffnen +2. **„Neue Tour"** antippen +3. Namen frei eingeben (z. B. „Stadtführung Nürnberg" oder „Wanderung Zugspitze") → **Speichern** +4. Die neue Tour erscheint sofort als Tab in der Liste. Leere Touren (ohne Wegpunkte) bleiben erhalten und werden persistent gespeichert. + +### Wegpunkt einer Tour zuordnen + +Beim Anlegen oder Bearbeiten eines Wegpunkts befindet sich im Dialog der Abschnitt **„Tour"**: + +- **Dropdown-Auswahl**: Bereits vorhandene Touren können direkt ausgewählt werden. +- **Freie Texteingabe**: Ein neuer Tourname kann direkt eingetippt werden. Ist der Name noch nicht vorhanden, wird er beim Speichern automatisch zur Tourliste hinzugefügt. +- **Leer lassen**: Bleibt das Feld leer, wird der Wegpunkt der **Standardtour** zugeordnet. +- **Neue Wegpunkte (GPS-FAB)**: Beim Erstellen eines Wegpunkts aus dem aktuellen GPS-Standort oder über die Kartenansicht wird die aktuell gewählte Tour automatisch vorausgefüllt. + +### Tour wechseln (Tab-Navigation) + +- Tippe auf einen **Tab** in der Tab-Leiste, um zu einer anderen Tour zu wechseln. +- Die Wegpunktliste zeigt dann nur die Wegpunkte dieser Tour. +- Auch die **Kartenansicht** zeigt nur die Wegpunkte der aktuell gewählten Tour (der Tourname ist in der Titelleiste der Karte sichtbar). + +### Tour umbenennen + +1. Zur gewünschten Tour wechseln (Tab antippen) +2. Overflow-Menü **(⋮)** → **„Tour umbenennen"** +3. Neuen Namen eingeben → **Speichern** +4. Alle Wegpunkte dieser Tour werden automatisch umbenannt. + +### Tour löschen + +1. Zur gewünschten Tour wechseln +2. Overflow-Menü **(⋮)** → **„Tour löschen“** +3. Den Sicherheitsdialog bestätigen: Alle Wegpunkte der Tour werden in die **Standardtour** verschoben. +4. Die UI wechselt sofort zur nächsten verfügbaren Tour – kein App-Neustart nötig. +5. Der GPS-Track der gelöschten Tour wird ebenfalls entfernt. + +**Standardtour**: Der Menüpunkt „Tour löschen“ ist deaktiviert (ausgegraut), wenn die Standardtour ausgewählt ist. Die Standardtour kann nicht gelöscht werden. + +--- + +## Manuelle Wiedergabe + +Die App bietet zwei Möglichkeiten zur manuellen Audiowiedergabe ohne GPS-Dienst: + +--- + +### 1. Einzel-Wiedergabe je Wegpunkt (per Karten-Schaltfläche) + +Jede Wegpunktkarte in der Hauptliste zeigt – sofern eine Audiodatei zugeordnet ist – eine **▶◉ Abspielen**-Schaltfläche direkt auf der Karte. + +**Verhalten:** + +- Tippt man auf die Schaltfläche, spielt **nur diese eine Datei** ab und stoppt danach automatisch. + Die Warteschlange wird **nicht** fortgesetzt – es wird kein weiterer Wegpunkt geladen. +- Ist derselbe Wegpunkt bereits aktiv, wechselt die Schaltfläche zu **⏸ Pause**; erneutes Tippen pausiert / setzt fort. +- Tippt man auf einen anderen Wegpunkt während einer Einzel-Wiedergabe läuft, wird die vorherige sofort gestoppt und die neue gestartet. +- Läuft die globale Wiedergabeleiste (Playlist-Modus), wird diese beim Start einer Einzel-Wiedergabe gestoppt. +- Hat ein Wegpunkt **keine Audiodatei**, erscheint stattdessen ein deaktiviertes `🔇`-Symbol mit dem Hinweis „Keine Audiodatei zugewiesen“. +- Die aktive Karte wird farblich hervorgehoben (primäre Hintergrundfarbe + Rahmen), damit sofort erkennbar ist, welcher Wegpunkt spielt. + +**Begleitmusik (Atmo):** +Das konfigurierte Atmo-Verhalten (Pause & Resume, Fade, Duck, Parallel) wird auch bei der Einzel-Wiedergabe angewendet. Die Atmo-Rückkehr erfolgt erst nach dem natürlichen Abschluss der Datei. + +**Einzel-Wiedergabe ist auch über die Karte verfügbar** – beim Antippen eines Markers erscheint (wenn Audiodatei vorhanden) ein `▶◉ Diesen Wegpunkt abspielen`-Knopf im Aktionsdialog. + +--- + +### 2. Playlist-Modus (globale Wiedergabeleiste) + +Am unteren Rand der Hauptübersicht befindet sich eine **Wiedergabeleiste** für die manuelle Steuerung ohne aktiven GPS-Dienst. + +#### Bedienung + +| Schaltfläche | Funktion | +|---|---| +| ⏮ Zurück | Springt zum vorherigen Wegpunkt der aktuellen Tour und startet die Wiedergabe | +| ▶/⏸ Play/Pause | Startet die Wiedergabe des ausgewählten Wegpunkts oder pausiert die laufende Wiedergabe | +| ⏭ Weiter | Springt zum nächsten Wegpunkt der aktuellen Tour und startet die Wiedergabe | + +> **Hinweis:** Während eine Einzel-Wiedergabe (Modus 1) aktiv ist, werden ⏮ Zurück und ⏭ Weiter in der Leiste **deaktiviert** (ausgegraut), da Einzel-Wiedergabe keine Queue-Fortsetzung kennt. Nur der Play/Pause-Knopf bleibt aktiv. Beim Betätigen von Play/Pause in der Leiste während Einzel-Modus: die Einzel-Wiedergabe wird gestoppt und der Playlist-Modus übernimmt. + +#### Wiedergabeliste + +Die Wiedergabeliste besteht aus allen **aktiven** Wegpunkten der aktuell gewählten Tour, die eine **Audiodatei** zugeordnet haben (in Listenreihenfolge). + +- Wenn kein Wegpunkt mit Audiodatei vorhanden ist, sind die Schaltflächen deaktiviert und es wird ein Hinweis angezeigt. +- Der Name des aktuell geladenen Wegpunkts wird über den Schaltflächen angezeigt: `Manuell: ` (Playlist) bzw. `Einzelwiedergabe: ` (Einzel-Modus). +- Pause hält die Wiedergabe an der aktuellen Position; Play setzt sie fort. +- Der manuelle Player ist vom GPS-Hintergrunddienst getrennt und beeinflusst diesen nicht. +- Beim Wechsel der Tour wird die manuelle Wiedergabe gestoppt. + +### Hintergrunddienst und Touren + +Der GPS-Hintergrundddienst überwacht **alle aktiven Wegpunkte aller Touren** gleichzeitig. Die Tour-Auswahl wirkt sich nur auf die Listenansicht und Kartenansicht aus – das Abspielen von Audiodateien beim Betreten eines Radius funktioniert tourunabhängig. + +### Import / Export mit Touren + +- **JSON-Export/-Import**: Das Feld `tourName` wird automatisch mit serialisiert. Ältere JSON-Dateien ohne `tourName` erhalten beim Import automatisch den Wert „Standard" (rückwärtskompatibel). +- **GPX-Export**: `tourName` wird als `` in den `` gespeichert. +- **GPX-Import**: `` wird beim Import übernommen. Fehlt das Feld, wird „Standard" verwendet. Importierte Touren werden automatisch zur Tourliste hinzugefügt. + +### Kartenansicht und Touren + +Die Kartenansicht zeigt immer nur die Wegpunkte der aktuell in der Listenansicht gewählten Tour. Der Tourname ist in der Titelleiste der Karte sichtbar, wenn mehr als eine Tour vorhanden ist. + +**Hinweis**: Neue Wegpunkte, die über den FAB „Als Wegpunkt übernehmen" in der Karte angelegt werden, erhalten automatisch die aktuell gewählte Tour. + +### MVP-Einschränkungen (Touren) + +- **Reihenfolge**: Touren werden in der Reihenfolge der Erstellung angezeigt; eine manuelle Umsortierung ist noch nicht möglich. +- **Löschen der Standardtour**: Die Tour „Standard" kann nicht gelöscht werden. +- **Dienst**: Der Hintergrunddienst überwacht alle Touren (nicht nur die gewählte). Dies ist gewollt, damit keine aktiven Wegpunkte versehentlich stumm geschaltet werden. + +--- + +## Begleitmusik (Atmo) + +GPS2Audio unterstützt optionale **Begleitmusik** (Hintergrundmusik) pro Tour. Die Musik läuft während der Tour im Hintergrund und reagiert automatisch auf Wegpunkt-Audio-Ereignisse. + +> **Hinweis:** Das Begleitmusik-Panel erscheint auf der Hauptseite unter dem Titel **„Atmo“** – die interne Bezeichnung lautet weiterhin Begleitmusik. + +### Atmo Mini-Player (Begleitmusik) + +Die Begleitmusik-Steuerung ist als kompakter **„Atmo“-Mini-Player** direkt in der Hauptübersicht sichtbar – unterhalb des Abschnitts „Wegpunkt-Tracks“ und oberhalb der manuellen Wiedergabeleiste. + +#### Anzeige-Elemente + +| Element | Beschreibung | +|---|---| +| **Titel** | Name des aktuell abgespielten Tracks (aus displayName der Playlist oder Stream-URL) | +| **Track-Badge** | Position in der Playlist (z. B. „2 / 5") oder „Stream" für Radio/Internet-Streams | +| **Fortschrittsbalken** | `LinearProgressIndicator` mit verstrichener / Gesamtdauer (nur lokale Dateien mit bekannter Länge) | +| **Zeitanzeige** | Verstrichene Zeit / Gesamtdauer (z. B. `1:23 / 4:05`) | +| **Live-Indikator** | Roter Punkt + „Live" für aktive Stream-Wiedergabe (kein Fortschrittsbalken) | +| **Nächster Titel** | Zeile „Als nächstes: …" für lokale Playlists mit >1 Track | +| **Verhalten-Badge** | Aktives Wegpunkt-Verhalten (Pause/Fade/Duck/Weiter) | +| **Autostart-Badge** | Erscheint, wenn „Autostart nach Waypoint" aktiviert ist | +| **Fehleranzeige** | Konfigurationsfehler oder fehlende Quellen direkt im Panel | + +#### Steuerung + +| Schaltfläche | Funktion | Verfügbarkeit | +|---|---|---| +| **⏮ Zurück** | Springt zum vorherigen Titel | Nur lokale Playlist mit >1 Track | +| **▶/⏸ Play/Pause** | Startet oder pausiert die Wiedergabe | Immer (wenn aktiviert & Quelle konfiguriert) | +| **⏭ Weiter** | Springt zum nächsten Titel | Nur lokale Playlist mit >1 Track | +| **⏹ Stop** | Stoppt die Wiedergabe und gibt den Player frei | Wenn ein Track geladen ist | +| **Konfigurieren** | Öffnet den vollständigen Begleitmusik-Dialog | Immer | + +> **Streams**: Voriger/Nächster-Schaltflächen werden für Streams automatisch deaktiviert (ausgegraut), da Streams keine Playlist-Navigation unterstützen. + +Das Overflow-Menü **(⋮)** enthält weiterhin den Eintrag „Begleitmusik" als zusätzlichen Zugangspunkt. + +### Aktivierung + +1. Im **Begleitmusik-Panel** auf **„Konfigurieren"** tippen (oder Overflow-Menü **(⋮)** → **„Begleitmusik"**) +2. **„Begleitmusik aktivieren"** einschalten +3. Musikquelle wählen und Einstellungen speichern +4. Play-Button im Panel drücken oder Begleitmusik startet automatisch (wenn Autostart aktiv) + +### Musikquellen + +#### Lokale Playlist + +- Tippe auf **„Dateien hinzufügen"** → Systemdateiauswahl öffnet sich +- Mehrere Audiodateien gleichzeitig auswählbar (MP3, OGG, FLAC, AAC usw.) +- Ausgewählte Dateien werden mit ihrem Dateinamen in der Playlist angezeigt +- URI-Berechtigungen werden dauerhaft gesichert (bleiben nach App-Neustart erhalten) +- Einzelne Dateien können per ✕-Button entfernt werden +- **„Playlist leeren"** löscht alle Einträge +- Option **„Zufällige Reihenfolge"** mischt die Playlist beim Abspielen +- Wiedergabe läuft in Dauerschleife (Repeat All) + +#### Stream-URL + +- Direkte **http:// oder https://**-URL zu einem Audio-Stream eingeben +- Geeignet für: Internetradio-Streams (ICY/Icecast, Shoutcast), direkte MP3-URLs, öffentliche Audio-Streams +- **Wichtig**: Es muss eine **direkte, abspielbare Audio-URL** sein – keine Webseiten-URL +- Playback via ExoPlayer (AndroidX Media3) – unterstützt HLS, DASH und klassische HTTP-Streams + +> **Hinweis zu YouTube, SoundCloud und radio.de:** +> Diese Plattformen liefern keine direkt abspielbaren Audio-URLs über ihre öffentlichen Links. Eine Standard-YouTube- oder SoundCloud-URL funktioniert nicht als Stream-URL. Stattdessen: +> - **Internetradio**: Viele Sender veröffentlichen direkte Stream-URLs (z. B. `.mp3`, `.ogg`, `.m3u8`) auf ihrer Website +> - **radio.de**: Die App-interne Stream-URL (im HTML-Quellcode o. ä.) funktioniert – die Webseiten-URL nicht +> - **YouTube / SoundCloud**: Inhalte über die jeweilige App oder den Browser abspielen; direkte Integration ist aus rechtlichen und technischen Gründen nicht vorgesehen +> - Bei Eingabe einer nicht abspielbaren URL zeigt der Player im Logcat einen Fehler; in der App erscheint die Begleitmusik einfach nicht + +### Verhalten beim Wegpunkt-Audio + +Vier Modi steuern, was mit der Begleitmusik passiert, wenn ein Wegpunkt-Ton abgespielt wird: + +| Modus | Beschreibung | +|---|---| +| **Pausieren und fortsetzen** (Standard) | Musik wird pausiert, solange der Wegpunkt-Ton läuft. Danach setzt sie an der gleichen Stelle fort. | +| **Fade-out / Fade-in** | Musik wird sanft ausgeblendet (Fade-out), Wegpunkt spielt, danach sanft eingeblendet (Fade-in). | +| **Leise unterlegen (Duck)** | Musik wird auf eine reduzierte Lautstärke abgesenkt (konfigurierbar), Wegpunkt spielt parallel. Nach dem Ton wird die Lautstärke wiederhergestellt. | +| **Normal unterlegen** | Musik läuft auf normaler Lautstärke weiter, Wegpunkt-Ton wird parallel abgespielt. | + +### Parameter + +| Parameter | Standard | Beschreibung | +|---|---|---| +| Fade-Dauer | 1500 ms | Dauer des Fade-out/Fade-in-Effekts (300–4000 ms, per Schieberegler) | +| Duck-Lautstärke | 25 % | Lautstärke der Begleitmusik im Duck-Modus (5–50 %, per Schieberegler) | + +### Nach Waypoint automatisch Begleitmusik starten (Autostart) + +Im Begleitmusik-Dialog gibt es die Option **„Nach Waypoint automatisch Begleitmusik starten"**: + +- **Deaktiviert** (Standard): Begleitmusik wird nach einem Wegpunkt-Ton nur dann wieder aufgenommen, wenn sie *vor* dem Wegpunkt bereits spielte. War die Musik gestoppt, bleibt sie nach dem Wegpunkt gestoppt. +- **Aktiviert**: Nach dem Ende des Wegpunkt-Tons startet die Begleitmusik *immer* – auch wenn sie vorher nicht spielte. Das gilt für manuelle Wiedergabe (`ManualAudioPlayer`) und GPS-ausgelöste Wegpunkte (`WaypointLocationService`). + +**Zusammenspiel mit den Verhalten-Modi:** + +| Modus | Autostart aktiv | Verhalten wenn Musik vorher nicht spielte | +|---|---|---| +| Pause/Resume | Ja | Musik startet nach dem Waypoint-Ton neu | +| Fade-out/Fade-in | Ja | Musik startet und blendet sanft ein | +| Duck | Ja | Musik startet auf normaler Lautstärke | +| Normal unterlegen | Ja | Musik startet sofort nach dem Waypoint | + +Das Autostart-Symbol erscheint als **Badge** im sichtbaren Begleitmusik-Panel der Hauptansicht. + +### Test / Vorschau + +Der Dialog enthält Steuerungsschaltflächen zum Testen: +- **Play/Pause** – Begleitmusik direkt im Dialog testen +- **Stop** – Musik sofort stoppen +- **⏮ / ⏭** – In der lokalen Playlist vor- und zurückspringen + +**Tipp**: Einstellungen vor dem Speichern mit Play testen, um sicherzustellen, dass die Quelle abspielbar ist. + +### Technische Hinweise (Akku & Datenverbrauch) + +- **Lokale Playlist**: Kein Datenverbrauch. Akku-Verbrauch vergleichbar mit anderen Musik-Apps (~5–15 % pro Stunde, geräteabhängig). +- **Stream-URL**: Kontinuierliche Mobilfunk-/WLAN-Verbindung erforderlich. Typischer Verbrauch je nach Stream-Qualität: 32–192 kbps (ca. 15–90 MB/Stunde). Bei schlechter Verbindung kann der Stream pausieren oder abbrechen. +- **ExoPlayer** (Media3) wird erst beim Starten der Begleitmusik initialisiert. Im deaktivierten Zustand (kein Overhead). +- **Akku-Hinweis**: WLAN-Streaming ist deutlich energieeffizienter als Mobilfunk-Streaming. + +### Fehlerbehebung: Begleitmusik startet nicht + +**Symptom:** Die Begleitmusik spielt nicht, obwohl sie aktiviert ist. + +#### Checkliste + +1. **Quelle konfiguriert?** + - Im Begleitmusik-Panel müssen entweder „Lokale Playlist: N Titel“ oder „Stream: …“ angezeigt werden + - Ist die Anzeige „Keine Quelle konfiguriert“, zuerst Dateien hinzufügen oder URL eingeben und **Speichern** drücken + +2. **Aktiviert?** + - Das Begleitmusik-Panel muss den farbigen sekundären Hintergrund zeigen („Begleitmusik aktivieren“ ist EIN) + - Grauer Hintergrund = nicht aktiviert + +3. **Play-Button direkt drücken** + - Im Begleitmusik-Panel auf ▶ drücken (nicht auf den Wegpunkt-Player warten) + - Erscheint eine Fehlermeldung im roten Banner? → Hinweis befolgen + +4. **URI-Berechtigungen für lokale Dateien** + - Dateien müssen über den **Systemdateimanager** innerhalb der App ausgewählt werden + - Manuell eingetippte Pfade funktionieren nicht + - URI-Berechtigung wird beim Auswählen automatisch dauerhaft gesichert + - Nach einem Factory Reset müssen Dateien neu hinzugefügt werden + +5. **Stream-URL** + - URL muss mit `http://` oder `https://` beginnen + - Es muss eine **direkt abspielbare** Audio-URL sein (kein YouTube, kein SoundCloud, keine radio.de-Webseite) + - URL in einem Browser öffnen und prüfen, ob Audio direkt gestreamt wird + - Fehler sichtbar im Begleitmusik-Panel-Banner oder im Logcat (`adb logcat -s BackgroundMusicPlayer`) + +6. **Einstellungen gespeichert?** + - Nach Änderungen im Dialog immer **„Speichern“** drücken (nicht nur schließen) + - Beim Schließen ohne Speichern („Abbrechen“) werden Änderungen verworfen + +7. **Autostart nach Waypoint aktivieren** + - Wenn die Begleitmusik nur nach Wegpunkten automatisch starten soll: Option **„Nach Waypoint automatisch Begleitmusik starten“** einschalten + - Ohne diese Option startet die Musik nur, wenn sie vor dem Wegpunkt bereits spielte + +#### Logcat-Debugging + +```bash +# Alle Begleitmusik-Logs anzeigen +adb logcat -s BackgroundMusicPlayer BackgroundMusicManager + +# Typische Fehlermeldungen +# W BackgroundMusicPlayer: Keine abspielbaren Quellen gefunden → URI ungültig oder Playlist leer +# W BackgroundMusicPlayer: Stream-URL ist keine gültige http/https-URL → URL-Format prüfen +# E BackgroundMusicPlayer: ExoPlayer Fehler: ... → Datei nicht lesbar oder Stream nicht verfügbar +``` + +### MVP-Einschränkungen (Begleitmusik) + +- **Wiederherstellung im GPS-Dienst**: Der `AudioPlayer` des GPS-Hintergrunddienstes (`WaypointLocationService`) unterstützt jetzt `onCompletion`- und `onError`-Callbacks. Die Begleitmusik wird exakt dann wiederhergestellt (Fade-in, Resume, Duck-Restore), wenn der Wegpunkt-Ton natürlich beendet ist oder ein Fehler aufgetreten ist – nicht mehr sofort nach dem Auslösen. Das Verhalten entspricht damit dem der manuellen Wiedergabe (`ManualAudioPlayer`). Der `playCount`-Zähler wird ebenfalls nur bei natürlicher Completion inkrementiert (nicht bei Wiedergabe-Fehlern). +- **GPS-Dienst und App-Prozess**: Begleitmusik-Player und GPS-Dienst laufen im selben App-Prozess. Wenn der Prozess vom System beendet wird, stoppt auch die Begleitmusik. +- **Stream-Fehler**: Bei nicht direkt abspielbaren URLs (z. B. YouTube-Links) erscheint ein Fehler-Banner im Begleitmusik-Panel. Im Logcat ist der ExoPlayer-Fehler zusätzlich ersichtlich. +- **Kein Crossfade**: Beim Wechsel von Playlist-Titeln gibt es kein Überblenden. +- **Einzel-Instanz**: Nur eine Begleitmusik-Quelle gleichzeitig. Beim Tourwechsel in der App wird die Musik der vorherigen Tour gestoppt und die Einstellungen der neuen Tour geladen. + +--- + +## Tour-Zähler (Zähler je Tour) + +GPS2Audio ermöglicht es, den **Abspiel-Zähler und Abspiel-Modus für alle Wegpunkte einer Tour in einem Schritt** zu steuern. Die Funktion erscheint als kompakte **Tour-Zähler-Karte** direkt unterhalb der Tour-Tabs und zeigt auf einen Blick, wie oft Wegpunkte der Tour abgespielt wurden. + +> **Hinweis:** Die Tour-Zähler-Karte erscheint nur, wenn die aktuelle Tour mindestens einen Wegpunkt enthält. + +### Tour-Zähler-Karte (Hauptansicht) + +Zwischen der Tab-Leiste und der Wegpunktliste wird eine kompakte Karte mit Zählerinformationen angezeigt: + +| Element | Beschreibung | +|---|---| +| **Titel** | „Tour-Zähler" | +| **Zusammenfassung** | Gesamtzahl aller Abspielungen (alle Wegpunkte der Tour) sowie die Anzahl an Wegpunkten mit begrenzen oder einmaligen Abspielregeln | +| **Schaltfläche „Einstellen"** | Öffnet den Zähler-Dialog für diese Tour | + +**Beispielausgaben:** +- `5× abgespielt · 2 begrenzt · 1 einmalig` +- `Alle Wegpunkte: bei jedem Betreten` (wenn alle Wegpunkte im Standard-Modus sind und noch nicht abgespielt wurden) + +### Tour-Zähler-Dialog + +Ein Tipp auf „Einstellen" öffnet den **Zähler-Dialog** mit zwei unabhängigen Aktionsbereichen: + +#### 1. Zähler zurücksetzen + +- Schaltfläche: **„Alle Zähler dieser Tour zurücksetzen"** +- Setzt den `playCount` aller Wegpunkte der aktuellen Tour auf **0** +- Gilt nur für die aktuelle Tour, andere Touren bleiben unberührt +- Der Abspiel-Modus (`EVERY_ENTRY`, `ONCE`, `LIMITED_COUNT`) und `maxPlayCount` werden nicht verändert + +> Nach dem Zurücksetzen können zuvor stumme Wegpunkte (weil Limit erreicht) erneut abgespielt werden. + +#### 2. Abspiel-Modus für alle Wegpunkte setzen + +Erlaubt es, den Abspiel-Modus aller Wegpunkte der aktuellen Tour gleichzeitig zu ändern: + +| Feld | Beschreibung | +|---|---| +| **Abspiel-Modus** | Dropdown: „Bei jedem erneuten Betreten" / „Nur einmal" / „Begrenzt oft" | +| **Maximale Abspielanzahl** | Nur sichtbar bei Modus „Begrenzt oft" – Eingabefeld für eine Zahl ≥ 1 | +| **Zähler beim Anwenden zurücksetzen** | Checkbox: Setzt `playCount` beim Anwenden auf 0 | + +Ein Tipp auf **„Auf alle Wegpunkte anwenden"** aktualisiert alle Wegpunkte der Tour: +- `playbackMode` wird auf den gewählten Modus gesetzt +- `maxPlayCount` wird gesetzt (bei `LIMITED_COUNT`) oder auf `null` zurückgesetzt +- `playCount` wird zurückgesetzt, wenn die Checkbox aktiviert war + +**Modi im Überblick:** + +| Modus | Wirkung | +|---|---| +| **Bei jedem erneuten Betreten** | Alle Wegpunkte spielen bei jedem Betreten – kein Limit | +| **Nur einmal** | Alle Wegpunkte spielen maximal einmal; `playCount = 0` ist Voraussetzung | +| **Begrenzt oft** | Alle Wegpunkte spielen maximal N-mal (N wird in der Eingabe festgelegt) | + +### Welche Wegpunkte sind betroffen? + +- Die Tour-Zähler-Aktionen betreffen immer **alle Wegpunkte der aktuell gewählten Tour** +- Die Aktionen gelten für aktive und inaktive Wegpunkte gleichermaßen +- Wegpunkte anderer Touren werden nicht berührt +- **Neue Wegpunkte**, die nach der Massen-Aktion angelegt werden, erben den gesetzten Modus **nicht automatisch** – sie erhalten den Standard-Modus (bei jedem Betreten). Für neue Wegpunkte muss der Modus einzeln im Bearbeitungsdialog oder durch erneute Massen-Aktion gesetzt werden. + +### Typische Anwendungsfälle + +**Saisonale Tour neu starten:** +1. Tour-Tab antippen +2. Tour-Zähler-Karte → „Einstellen" +3. „Alle Zähler dieser Tour zurücksetzen" → alle Wegpunkte können erneut abgespielt werden + +**Alle Wegpunkte auf einmaliges Abspielen umstellen (Messeführung):** +1. Tour-Tab antippen +2. Tour-Zähler-Dialog öffnen +3. Modus „Nur einmal" wählen + Checkbox „Zähler beim Anwenden zurücksetzen" aktivieren +4. „Auf alle Wegpunkte anwenden" + +**Begrenzte Anzahl für alle setzen (z. B. max. 3×):** +1. Tour-Tab antippen +2. Tour-Zähler-Dialog öffnen +3. Modus „Begrenzt oft", Anzahl `3`, Checkbox „Zähler zurücksetzen" aktivieren +4. „Auf alle Wegpunkte anwenden" + +### Verhältnis zu einzelnen Wegpunkt-Einstellungen + +Die Tour-Zähler-Massen-Aktion **überschreibt** die individuellen Modus-Einstellungen aller Wegpunkte der Tour. Danach können einzelne Wegpunkte über den Bearbeitungsdialog (Bleistift-Symbol) individuell angepasst werden – der per Massen-Aktion gesetzte Modus gilt dann als Ausgangspunkt. + +### GPX/JSON-Kompatibilität + +- Die Tour-Zähler-Funktion arbeitet ausschließlich auf den vorhandenen Wegpunkt-Feldern (`playbackMode`, `maxPlayCount`, `playCount`). +- GPX- und JSON-Import werden durch die Tour-Zähler-Funktion **nicht beeinflusst**. +- Ein nach dem Import durchgeführtes „Zurücksetzen" oder „Modus anwenden" wirkt auf die soeben importierten Wegpunkte. + +--- + +## Tour-Abspiel-Vorgaben (Vorgaben für neue Wegpunkte) + +GPS2Audio merkt sich die zuletzt im Tour-Zähler-Dialog gesetzten Abspielregeln als **Tour-Vorgabe** und wendet sie automatisch auf neu angelegte Wegpunkte an. + +### So funktioniert es + +1. **Tour-Zähler-Dialog öffnen**: Tour-Zähler-Karte → „Einstellen" +2. Gewünschten Abspiel-Modus wählen (z. B. „Nur einmal" oder „Begrenzt oft, max. 3×") +3. **„Auf alle Wegpunkte anwenden"** tippen + +Ab diesem Moment gilt die Einstellung als **Tour-Vorgabe**: Alle neu erstellten Wegpunkte dieser Tour (über den +FAB, den Standort-FAB oder die Kartenansicht) erben automatisch diesen Abspiel-Modus und die maximale Abspielanzahl. + +### Hinweis im Wegpunkt-Dialog + +Wenn eine abweichende Tour-Vorgabe aktiv ist (also nicht der Standardmodus „Bei jedem Betreten"), erscheint im Wegpunkt-Erstellungsdialog ein farbiger Hinweis: + +> „Abspielregel aus Tour-Vorgabe übernommen. Kann hier überschrieben werden." + +Der Modus kann im Dialog jederzeit für den einzelnen Wegpunkt überschrieben werden. + +### Wo wird gespeichert? + +Die Tour-Vorgaben werden separat (im DataStore unter `tour_playback_defaults_json`) pro Tour gespeichert und überleben App-Neustarts. + +### Einschränkungen + +- Tour-Vorgaben werden nur beim **Anwenden im Tour-Zähler-Dialog** gesetzt, nicht durch Bearbeitung einzelner Wegpunkte. +- Die Vorgabe gilt nur für **neu erstellte** Wegpunkte; bereits vorhandene Wegpunkte werden nicht berührt (es sei denn, „Auf alle Wegpunkte anwenden" wird erneut verwendet). + +--- + +## Karten-Editor + +Die Kartenansicht ist nun ein vollwertiger **Wegpunkt-Editor** für die gewählte Tour. + +### Wegpunkte auf der Karte + +- Alle Wegpunkte der gewählten Tour werden als **Marker** mit Radius-Kreis angezeigt. +- Inaktive Wegpunkte erscheinen halbtransparent. +- Die Tour ist in der Titelleiste sichtbar. + +### Wegpunkt antippen → Aktionsmenü + +Ein Tipp auf einen Wegpunkt-Marker öffnet ein Dialog-Menü mit: + +| Aktion | Beschreibung | +|---|---| +| **Bearbeiten** | Öffnet den Wegpunkt-Dialog mit allen vorhandenen Daten | +| **Löschen** | Öffnet Bestätigungsdialog „Wegpunkt löschen?" → erst nach Bestätigung wird gelöscht | + +### Langer Druck → Neuer Wegpunkt + +Ein **langer Druck** auf eine beliebige Kartenposition öffnet den Wegpunkt-Erstellungsdialog mit den Koordinaten der gedrückten Position vorausgefüllt. + +- Tour-Vorgaben (Abspielregel) werden automatisch übernommen. +- Tour wird automatisch auf die aktuell gewählte Tour gesetzt. + +### FAB: Wegpunkt aus aktuellem Standort + +Wie bisher: GPS-Position ermitteln → „Als Wegpunkt übernehmen" (erweiterter FAB). + +### FAB: Wegpunkt am Track-Ende + +Ist ein GPS-Track aufgezeichnet oder gespeichert, erscheint ein zusätzlicher FAB **„Wegpunkt am Track-Ende"**: Er öffnet den Wegpunkt-Dialog mit dem letzten aufgezeichneten Track-Punkt als Koordinaten. + +### Editor-Hinweis + +Am unteren Rand der Karte erscheint dauerhaft der Hinweis: **„Marker antippen · Karte lange drücken"**, solange kein Track-Panel sichtbar ist. + +--- + +## GPS-Track-Aufzeichnung + +GPS2Audio kann GPS-Routen (Tracks) **auch im Hintergrund** aufzeichnen, als Polyline auf der Karte darstellen und pro Tour persistent speichern. + +Die Aufzeichnung läuft als **Foreground Service** (`TrackRecordingService`) – auch wenn die App in den Hintergrund wechselt, bleibt der Track aktiv. Eine permanente Benachrichtigung „GPS2Audio – Track-Aufzeichnung" zeigt den laufenden Service an. + +### Track aufzeichnen + +1. Kartenansicht öffnen (🗺-Symbol in der Titelleiste) +2. Auf den roten **Aufnahme-FAB** (Kreis-Symbol, unterer Bereich) tippen → Aufzeichnung startet +3. Während der Aufzeichnung: + - Alle **5 Sekunden** (mind. 5 m Abstand) wird ein Punkt aufgezeichnet + - Eine rote **Polyline** erscheint auf der Karte und wächst mit jedem neuen Punkt + - Das **Track-Statistik-Panel** (links unten) zeigt: Punktanzahl, Distanz, Aufnahmedauer + - **Die App kann in den Hintergrund wechseln** – die Aufzeichnung läuft weiter +4. Auf den **Stopp-FAB** (rotes „Stopp"-Symbol) tippen → Aufzeichnung endet, Track wird gespeichert + +### Track-Statistik-Panel + +| Anzeige | Beschreibung | +|---|---| +| „Aufzeichnung läuft" / roter Punkt | Zeigt an, dass gerade aufgezeichnet wird | +| Punktanzahl | Anzahl der aufgezeichneten GPS-Punkte | +| Distanz | Gesamtlänge des Tracks in Metern oder Kilometern (Haversine-Berechnung) | +| Aufnahmedauer | Vergangene Zeit seit Start (MM:SS oder HH:MM:SS) | +| „Track gespeichert" | Track ist gespeichert (nicht gerade aktiv aufzeichnend) | +| „Track löschen" | Entfernt Track aus Speicher und Karte | + +### Persistenz + +Der aufgezeichnete Track wird **nach dem Stoppen automatisch** im DataStore unter `gps_tracks_json` pro Tour gespeichert. Beim erneuten Öffnen der Karte mit derselben Tour wird der gespeicherte Track wiederhergestellt und angezeigt. + +### Wegpunkt am Track-Ende erstellen + +Ist ein Track vorhanden, erscheint der FAB **„Wegpunkt am Track-Ende"** (türkis/tertiäre Farbe, Timeline-Symbol). Ein Tipp öffnet den Wegpunkt-Dialog mit dem letzten Track-Punkt als Koordinaten. + +### Langer Druck entlang des Tracks + +Über **langen Druck** auf die Karte (auch auf den Track) kann an jeder beliebigen Stelle ein Wegpunkt erstellt werden – auch entlang des aufgezeichneten Tracks. + +### Einschränkungen (GPS-Track) + +- **Hintergrundaufzeichnung**: Die Track-Aufzeichnung läuft als Foreground Service auch wenn die App minimiert oder die Karte verlassen wird. Eine dauerhafte Systembenachrichtigung „GPS2Audio – Track-Aufzeichnung" ist während der Aufzeichnung sichtbar. +- **Berechtigung**: Für die Aufzeichnung wird `ACCESS_FINE_LOCATION` (oder `ACCESS_COARSE_LOCATION`) benötigt. Ein Foreground Service des Typs `location` kann mit der normalen Vordergrund-Berechtigung im Hintergrund laufen – **keine** `ACCESS_BACKGROUND_LOCATION`-Berechtigung erforderlich. +- **Hersteller-Einschränkungen**: Einige Android-Hersteller (Huawei EMUI, Xiaomi MIUI, Samsung One UI) können Foreground Services aggressiv stoppen. Falls der Track im Hintergrund abbricht, bitte unter Einstellungen → Apps → GPS2Audio → Akku → „Nicht optimieren" / „Keine Einschränkungen" setzen. +- **Kein Konflikt mit dem GPS-Dienst**: Track-Aufzeichnung (`TrackRecordingService`) und GPS-Wegpunkt-Dienst (`WaypointLocationService`) laufen unabhängig voneinander mit eigenen Notification-IDs (2001 vs. 1001). +- **Ein Track pro Tour**: Jede Tour kann genau einen gespeicherten Track haben. Ein neuer Track überschreibt den vorherigen nach dem Stoppen. +- **Genauigkeit**: Mindestens Android API 26, FusedLocationProviderClient mit HIGH_ACCURACY (wenn Berechtigung vorhanden), Mindestabstand 5 m. +- **Akku**: Aktive GPS-Aufzeichnung erhöht den Akku-Verbrauch spürbar. Für Langzeitaufnahmen: Akku-Optimierung der App deaktivieren (s. o.). + +### Berechtigungen für Hintergrund-Track-Aufzeichnung + +| Berechtigung | Erforderlich | Hinweis | +|---|---|---| +| `ACCESS_FINE_LOCATION` | Empfohlen | Für genaue GPS-Daten | +| `ACCESS_COARSE_LOCATION` | Mindestanforderung | Weniger genaue Tracks | +| `FOREGROUND_SERVICE` | Ja | Automatisch erteilt | +| `FOREGROUND_SERVICE_LOCATION` | Ja | Für Foreground Service Typ Location | +| `ACCESS_BACKGROUND_LOCATION` | Nein | Nicht erforderlich – Foreground Service Typ Location reicht | + +> **Hinweis Android 12+**: Beim ersten Start des Track-Aufzeichnungs-Service erscheint die Systemmeldung „GPS2Audio greift auf deinen Standort zu". Das ist erwartetes Verhalten. + +--- + +## Live / PTT – Mikrofon-Übertragung + +### Übersicht + +Die **Live/PTT-Funktion** (Push-to-Talk) ermöglicht es, das Mikrofon in Echtzeit an den Lautsprecher oder ein angeschlossenes Ausgabegerät weiterzuleiten. Die Karte „Live / PTT" befindet sich auf der Hauptseite zwischen dem Atmo-Bereich und den Wegpunkt-Tracks. + +### Audio-Routing (Gerätewahl) + +Über den **Zahnrad-Button** in der PTT-Karte oder den Button „Audiogeräte" öffnet sich der Gerätedialog: + +- **Eingabegerät**: Welches Mikrofon soll für PTT verwendet werden? + - Intern, Bluetooth-Headset, USB-Headset, ... +- **Ausgabegerät**: Wohin soll das PTT-Audio geleitet werden? + - Lautsprecher, Bluetooth-Kopfhörer, ... +- Wahl „Systemstandard" nutzt das vom Betriebssystem gewählte Standardgerät. +- Die gewählten Geräte werden **global gespeichert** (DataStore) und beim nächsten Start wiederhergestellt. + +Die Gerätewahl gilt nur für PTT; reguläre Atmo- und Wegpunkt-Wiedergabe verwenden die system-eigene Audiorouting-Logik. + +### Berechtigungen + +Beim ersten Tippen auf „PTT starten" wird die Laufzeit-Berechtigung `RECORD_AUDIO` angefordert. Ohne diese Berechtigung kann kein Mikrofonsignal erfasst werden. Die App funktioniert in allen anderen Bereichen ohne diese Berechtigung normal weiter. + +### Audio-Priorität + +| Situation | Verhalten | +|---|---| +| PTT aktiv | Atmo (Begleitmusik) wird pausiert | +| GPS-Wegpunkt während PTT | Wiedergabe wird aufgeschoben und nach PTT-Ende nachgeholt | +| Manuelle Wegpunkt-Wiedergabe während PTT | Blockiert mit Hinweis; nach PTT-Ende möglich | +| PTT endet | Atmo-Wiedergabe wird wiederhergestellt | + +### Bekannte Einschränkungen + +| Einschränkung | Erklärung | +|---|---| +| **Echo / Feedback** | Ohne Headset hört das Mikrofon den Lautsprecher – Echo ist zu erwarten. Das App-UI zeigt eine Warnung. Für Echo-freien Betrieb ein Headset verwenden. | +| **Bluetooth-Latenz** | Bluetooth SCO/A2DP hat systembedingte Verzögerungen von 50–200 ms. Für Echtzeit-Sprache empfehlen sich kabelgebundene oder USB-Headsets. | +| **Geräte-ID-Änderungen** | Android vergibt Geräte-IDs (`AudioDeviceInfo.id`) sitzungsspezifisch. Nach einem Neustart oder Trennen/Verbinden eines Geräts kann eine gespeicherte ID ungültig werden. Die App fällt dann automatisch auf den Systemstandard zurück und zeigt ggf. einen Hinweis im Log. | +| **setPreferredDevice()-Support** | Nicht alle Android-Geräte oder Hersteller-ROMs respektieren `AudioRecord.setPreferredDevice()` vollständig. Auf nicht kompatiblen Geräten wird der Systemstandard verwendet; ein Log-Eintrag dokumentiert den Fallback. | +| **Hintergrundmikrofon-Benachrichtigung** | Android 9+ zeigt eine Systembenachrichtigung wenn eine App das Mikrofon im Vorderground-Service nutzt. Diese Benachrichtigung ist vom System vorgegeben und kann nicht unterdrückt werden. | +| **Keine Echo-Unterdrückung (AEC)** | Der MVP implementiert keine Software-AEC. Für Echo-Unterdrückung kann `AcousticEchoCanceler.create()` nachgerüstet werden, sofern die Hardware es unterstützt. | +| **Kein Hintergrund-PTT nach App-Kill** | Der LivePttService stoppt wenn der App-Prozess vom System beendet wird. PTT ist primär für Vordergrundnutzung ausgelegt. | + +### Technische Details + +- **Implementierung**: `LivePttService` (Foreground Service, Typ `microphone`) mit `AudioRecord` (Eingang) und `AudioTrack` (Ausgang) +- **Audio-Format**: PCM 16-Bit, Mono, 16 kHz Abtastrate +- **Routing-Einstellungen**: Persistiert in `audio_routing_settings` (Jetpack DataStore) +- **Geräteinformationen**: `AudioDeviceManager` kapselt `AudioManager.getDevices()` mit deutschen Gerätenamen diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3bcfc99 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..946df69 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project specific ProGuard rules here. +-keep class de.waypointaudio.data.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1ec779d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/de/waypointaudio/MainActivity.kt b/app/src/main/kotlin/de/waypointaudio/MainActivity.kt new file mode 100644 index 0000000..036fcab --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/MainActivity.kt @@ -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()) + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt b/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt new file mode 100644 index 0000000..f2dbcaf --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/WaypointApp.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt new file mode 100644 index 0000000..e9f3fea --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/AudioRoutingSettings.kt @@ -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 = 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 + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/GpsTrackPoint.kt b/app/src/main/kotlin/de/waypointaudio/data/GpsTrackPoint.kt new file mode 100644 index 0000000..0df11b1 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/GpsTrackPoint.kt @@ -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() +) diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt b/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt new file mode 100644 index 0000000..112cb07 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TourAudioSettings.kt @@ -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.0–1.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 = 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 +) diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt b/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt new file mode 100644 index 0000000..b055f85 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TourMusicStore.kt @@ -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 by preferencesDataStore(name = "tour_music") + +/** + * Persistiert Begleitmusik-Einstellungen pro Tour (Key: Tourname). + * Serialisiert als JSON Map. + */ +class TourMusicStore(private val context: Context) { + + private val gson: Gson = GsonBuilder().create() + private val mapType = object : TypeToken>() {}.type + + companion object { + private val KEY_MUSIC_SETTINGS = stringPreferencesKey("music_settings_json") + } + + /** Flow der Begleitmusik-Einstellungen für alle Touren. */ + val settingsFlow: Flow> = + context.musicDataStore.data.map { prefs -> + val json = prefs[KEY_MUSIC_SETTINGS] ?: "{}" + runCatching { + val raw: Map? = 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 = + 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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(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 = 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 + ) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/TourPlaybackDefaults.kt b/app/src/main/kotlin/de/waypointaudio/data/TourPlaybackDefaults.kt new file mode 100644 index 0000000..b562123 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/TourPlaybackDefaults.kt @@ -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 +) diff --git a/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt b/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt new file mode 100644 index 0000000..eb97c19 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/Waypoint.kt @@ -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 (0–1439), ab der das tägliche Zeitfenster beginnt + * @param allowedEndMinutes Tagesminute (0–1439), 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" + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt new file mode 100644 index 0000000..6283aae --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/data/WaypointStore.kt @@ -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 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>() {}.type + private val stringListType = object : TypeToken>() {}.type + private val defaultsMapType = object : TypeToken>() {}.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> = context.dataStore.data.map { prefs -> + val json = prefs[KEY_WAYPOINTS] ?: "[]" + runCatching { + gson.fromJson>(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> = context.dataStore.data.map { prefs -> + val json = prefs[KEY_TOURS] ?: "[]" + val stored = runCatching { + gson.fromJson>(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> = + context.dataStore.data.map { prefs -> + val json = prefs[KEY_TOUR_DEFAULTS] ?: "{}" + runCatching { + gson.fromJson>(json, defaultsMapType) ?: emptyMap() + }.getOrDefault(emptyMap()) + } + + /** GPS-Track-Flow: Map tour → Liste von TrackPoint. */ + val gpsTracksFlow: Flow>> = + context.dataStore.data.map { prefs -> + val json = prefs[KEY_GPS_TRACKS] ?: "{}" + runCatching { + val mapType = object : TypeToken>>() {}.type + gson.fromJson>>(json, mapType) ?: emptyMap() + }.getOrDefault(emptyMap()) + } + + /** Speichert die gesamte Wegpunktliste. */ + suspend fun saveAll(waypoints: List) { + context.dataStore.edit { prefs -> + prefs[KEY_WAYPOINTS] = gson.toJson(waypoints) + } + } + + /** Speichert die gesamte Tourliste (Reihenfolge bleibt erhalten). */ + suspend fun saveTours(tours: List) { + 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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(json, defaultsMapType) ?: emptyMap() + }.getOrDefault(emptyMap()) + return map[tourName] ?: TourPlaybackDefaults(tourName = tourName) + } + + /** Speichert den GPS-Track einer Tour. */ + suspend fun saveGpsTrack(tourName: String, points: List) { + context.dataStore.edit { prefs -> + val json = prefs[KEY_GPS_TRACKS] ?: "{}" + val mapType = object : TypeToken>>() {}.type + val map: MutableMap> = runCatching { + gson.fromJson>>(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>>() {}.type + val map: MutableMap> = runCatching { + gson.fromJson>>(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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(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 = runCatching { + gson.fromJson>(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 + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/io/ImportExportManager.kt b/app/src/main/kotlin/de/waypointaudio/io/ImportExportManager.kt new file mode 100644 index 0000000..9226e6d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/io/ImportExportManager.kt @@ -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 -Elementen. + * - lat, lon als Attribute + * - aus waypoint.name + * - enthält Radius und Sound-Name (wenn vorhanden) + * - : , , und alle Abspielregel-Felder + */ +object ImportExportManager { + + private val gson: Gson = GsonBuilder().setPrettyPrinting().create() + private val waypointListType = object : TypeToken>() {}.type + + // ------------------------------------------------------------------------- + // JSON Export + // ------------------------------------------------------------------------- + + /** + * Serialisiert die Wegpunktliste als JSON-String. + * tourName wird automatisch durch Gson serialisiert (Kotlin data class). + */ + fun toJson(waypoints: List): 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): 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, String?> { + return runCatching, String?>> { + val json = context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } + ?: return Pair(emptyList(), "Datei konnte nicht gelesen werden") + val list: List? = 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 Erweiterungen. + */ + fun toGpx(waypoints: List): 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): 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, String?> { + return runCatching, String?>> { + val inputStream = context.contentResolver.openInputStream(uri) + ?: return Pair(emptyList(), "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() + + // 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(), "GPX enthält keine gültigen Wegpunkte ( mit lat/lon)") + } else { + Pair(waypoints, null) + } + }.getOrElse { e -> + Pair(emptyList(), "GPX-Fehler: ${e.localizedMessage}") + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt new file mode 100644 index 0000000..a065223 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/repository/WaypointRepository.kt @@ -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> = store.waypointsFlow + + /** Reaktiver Flow der persistierten Tourliste. */ + val tours: Flow> = store.toursFlow + + /** Reaktiver Flow der Tour-weiten Abspiel-Vorgaben. */ + val tourDefaults: Flow> = store.tourDefaultsFlow + + /** Reaktiver Flow der gespeicherten GPS-Tracks. */ + val gpsTracks: Flow>> = 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) = store.saveAll(waypoints) + + /** Tourliste persistieren. */ + suspend fun saveTours(tours: List) = 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) = + store.saveGpsTrack(tourName, points) + + /** Löscht den GPS-Track einer Tour. */ + suspend fun clearGpsTrack(tourName: String) = + store.clearGpsTrack(tourName) +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/AudioDeviceManager.kt b/app/src/main/kotlin/de/waypointaudio/service/AudioDeviceManager.kt new file mode 100644 index 0000000..02ea317 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/AudioDeviceManager.kt @@ -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 { + 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 { + 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 + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt new file mode 100644 index 0000000..0967102 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/AudioPlayer.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicManager.kt b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicManager.kt new file mode 100644 index 0000000..e68e1ce --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicManager.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt new file mode 100644 index 0000000..5b946b8 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt @@ -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 = emptyList() + + // ----------------------------------------------------------------------- + // Öffentlicher State-Flow + // ----------------------------------------------------------------------- + + private val _playbackState = MutableStateFlow(MusicPlaybackState()) + /** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */ + val playbackState: StateFlow = _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): List { + 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 { + 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 } + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt b/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt new file mode 100644 index 0000000..3c8fd5f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/LivePttManager.kt @@ -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 = _pttActive.asStateFlow() + + /** Aktuell gespeicherte Routing-Einstellungen. */ + private val _routingSettings = MutableStateFlow(AudioRoutingSettings()) + val routingSettings: StateFlow = _routingSettings.asStateFlow() + + /** Verfügbare Eingabegeräte (aktualisiert beim Öffnen der Einstellungen). */ + private val _inputDevices = MutableStateFlow>(emptyList()) + val inputDevices: StateFlow> = _inputDevices.asStateFlow() + + /** Verfügbare Ausgabegeräte (aktualisiert beim Öffnen der Einstellungen). */ + private val _outputDevices = MutableStateFlow>(emptyList()) + val outputDevices: StateFlow> = _outputDevices.asStateFlow() + + /** + * Status-/Fehlermeldung für die UI. + * null = kein Fehler / kein Hinweis + */ + private val _statusMessage = MutableStateFlow(null) + val statusMessage: StateFlow = _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 + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/LivePttService.kt b/app/src/main/kotlin/de/waypointaudio/service/LivePttService.kt new file mode 100644 index 0000000..f5a5e8b --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/LivePttService.kt @@ -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 (50–200 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() + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt new file mode 100644 index 0000000..5acce80 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/ManualAudioPlayer.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingManager.kt b/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingManager.kt new file mode 100644 index 0000000..445067e --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingManager.kt @@ -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 = _isRecording.asStateFlow() + + // Aktuell aufgezeichnete Track-Punkte (In-Memory) + private val _currentTrack = MutableStateFlow>(emptyList()) + val currentTrack: StateFlow> = _currentTrack.asStateFlow() + + // Name der Tour, für die gerade aufgenommen wird + private val _recordingTourName = MutableStateFlow(null) + val recordingTourName: StateFlow = _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) { + _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 + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingService.kt b/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingService.kt new file mode 100644 index 0000000..af5a32b --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/TrackRecordingService.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt b/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt new file mode 100644 index 0000000..ef1383a --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/service/WaypointLocationService.kt @@ -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() + + 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 = runCatching { + repository.waypoints.first() + }.getOrDefault(emptyList()) + + val currentInside = mutableSetOf() + + 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)) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt new file mode 100644 index 0000000..a565478 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/AboutDialog.kt @@ -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 + ) + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt new file mode 100644 index 0000000..c267e07 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/BegleitmusikDialog.kt @@ -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 -> + 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) + ) + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt b/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt new file mode 100644 index 0000000..5a4a33d --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/LivePttCard.kt @@ -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)) + } + } + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt new file mode 100644 index 0000000..db6673f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/MapScreen.kt @@ -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(null) } + var myLocationMarker by remember { mutableStateOf(null) } + var currentLocation by remember { mutableStateOf?>(null) } + var showAddWaypointAction by remember { mutableStateOf(false) } + + // Editor-Dialog-State + var editDialogWaypoint by remember { mutableStateOf(null) } + var editDialogPrefill by remember { mutableStateOf?>(null) } + var showEditDialog by remember { mutableStateOf(false) } + + // Wegpunkt-Aktionsmenü (Marker antippen) + var actionMenuWaypoint by remember { mutableStateOf(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(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, + trackPoints: List, + 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().toSet()) + val waypointMarkers = overlays.filterIsInstance() + .filter { it.title != "Mein Standort" } + overlays.removeAll(waypointMarkers.toSet()) + overlays.removeAll(overlays.filterIsInstance().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() + 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): 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) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/PermissionScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/PermissionScreen.kt new file mode 100644 index 0000000..2fb6262 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/PermissionScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/TourCounterDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/TourCounterDialog.kt new file mode 100644 index 0000000..375e747 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/TourCounterDialog.kt @@ -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) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt new file mode 100644 index 0000000..f5a2d78 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointEditDialog.kt @@ -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? = null, + existingTours: List = 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(waypoint?.scheduleStartMillis) } + var scheduleEndMillis by remember { mutableStateOf(waypoint?.scheduleEndMillis) } + var allowedStartMinutes by remember { mutableStateOf(waypoint?.allowedStartMinutes) } + var allowedEndMinutes by remember { mutableStateOf(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)) + } + } + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt new file mode 100644 index 0000000..3044fbc --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/WaypointListScreen.kt @@ -0,0 +1,1560 @@ +package de.waypointaudio.ui + +import android.Manifest +import android.content.pm.PackageManager +import java.util.Locale +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeOff +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 androidx.core.content.ContextCompat +import de.waypointaudio.R +import de.waypointaudio.data.MusicSourceType +import de.waypointaudio.data.PlaybackMode +import de.waypointaudio.data.TourAudioSettings +import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.Waypoint +import de.waypointaudio.data.WaypointMusicBehavior +import de.waypointaudio.service.MusicPlaybackState +import de.waypointaudio.viewmodel.WaypointViewModel + +/** + * Hauptbildschirm: Listet Wegpunkte einer Tour auf, mit Tab-Leiste für Touren. + * + * TopAppBar-Aktionen: + * ▶/■ – Dienst starten/stoppen + * 🗺 – Zur Kartenansicht wechseln + * ⋮ – Overflow-Menü: JSON/GPX Import & Export, Neue Tour, Umbenennen, Löschen, Begleitmusik + * + * Tabs: Eine Tab pro Tour, abgeleitet aus der persistierten Tourliste. + * FAB: + neuer Wegpunkt (Dialog, vorausgefüllt mit aktuell gewählter Tour) + * FAB2: 📍 Wegpunkt aus aktueller GPS-Position anlegen + * Atmo-Karte (Begleitmusik): Sichtbar unterhalb der Wegpunktliste, Titel auf der Hauptseite = "Atmo" + * Manueller Player: Leiste am unteren Rand für manuelle Audio-Wiedergabe + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WaypointListScreen( + viewModel: WaypointViewModel, + onNavigateToMap: () -> Unit +) { + val context = LocalContext.current + val waypoints by viewModel.waypoints.collectAsState() + val tourList by viewModel.tourList.collectAsState() + val selectedTour by viewModel.selectedTour.collectAsState() + val serviceRunning by viewModel.serviceRunning.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val currentLocation by viewModel.currentLocation.collectAsState() + val locationLoading by viewModel.locationLoading.collectAsState() + + // Manual player state + val manualPlaying by viewModel.manualPlaying.collectAsState() + val manualCurrentName by viewModel.manualCurrentName.collectAsState() + + // Single-item playback state (per-waypoint card buttons) + val singlePlayingWaypointId by viewModel.singlePlayingWaypointId.collectAsState() + val singlePlayingActive by viewModel.singlePlayingActive.collectAsState() + + // Begleitmusik state + val musicSettings by viewModel.musicSettings.collectAsState() + val musicPlaying by viewModel.musicPlaying.collectAsState() + val musicError by viewModel.musicError.collectAsState() + val musicPlaybackState by viewModel.musicPlaybackState.collectAsState() + + // Tour-weite Abspiel-Vorgaben (für neue Wegpunkte und TourCounterDialog) + val tourDefaults by viewModel.tourDefaults.collectAsState() + + // Filter waypoints to only show the selected tour + val filteredWaypoints = waypoints.filter { + it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == selectedTour + } + + // Playable playlist for manual controls + val playableCount = remember(waypoints, selectedTour) { + viewModel.manualPlaylist().size + } + + var showAddDialog by remember { mutableStateOf(false) } + var editingWaypoint by remember { mutableStateOf(null) } + var deleteCandidate by remember { mutableStateOf(null) } + var showOverflowMenu by remember { mutableStateOf(false) } + var showAboutDialog by remember { mutableStateOf(false) } + var showBegleitmusikDialog by remember { mutableStateOf(false) } + + // "Neue Tour" Dialog + var showNewTourDialog by remember { mutableStateOf(false) } + // "Tour umbenennen" Dialog + var showRenameTourDialog by remember { mutableStateOf(false) } + // "Tour löschen" Bestätigung + var showDeleteTourDialog by remember { mutableStateOf(false) } + // "Tour-Zähler" Dialog + var showTourCounterDialog by remember { mutableStateOf(false) } + + // Launcher für nachträgliche Standortberechtigung + val locationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + val granted = results[Manifest.permission.ACCESS_FINE_LOCATION] == true || + results[Manifest.permission.ACCESS_COARSE_LOCATION] == true + if (granted) { + viewModel.fetchCurrentLocation() + } + } + + // Wenn eine GPS-Position eintrifft, Dialog öffnen + LaunchedEffect(currentLocation) { + if (currentLocation != null) { + showAddDialog = true + } + } + + // ----------------------------------------------------------------------- + // File pickers / document launchers + // ----------------------------------------------------------------------- + + val exportJsonLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + uri?.let { viewModel.exportJson(it) } + } + + val importJsonLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.importJson(it) } + } + + val exportGpxLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/gpx+xml") + ) { uri -> + uri?.let { viewModel.exportGpx(it) } + } + + val importGpxLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { viewModel.importGpx(it) } + } + + // ----------------------------------------------------------------------- + // GPS-Button Click Handler + // ----------------------------------------------------------------------- + val onGpsButtonClicked: () -> Unit = { + if (!locationLoading) { + val fineGranted = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val coarseGranted = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + if (fineGranted || coarseGranted) { + viewModel.fetchCurrentLocation() + } else { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + } + } + + // ----------------------------------------------------------------------- + // Scaffold + // ----------------------------------------------------------------------- + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "GPS2Audio", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary + ), + actions = { + // Dienst-Schalter + IconButton(onClick = { + if (serviceRunning) viewModel.stopService() + else viewModel.startService() + }) { + Icon( + imageVector = if (serviceRunning) Icons.Filled.Stop else Icons.Filled.PlayArrow, + contentDescription = if (serviceRunning) + stringResource(R.string.service_stop) + else + stringResource(R.string.service_start) + ) + } + + // Kartenansicht + IconButton(onClick = onNavigateToMap) { + Icon( + Icons.Filled.Map, + contentDescription = stringResource(R.string.map_view) + ) + } + + // Overflow-Menü (Import/Export + Tourenverwaltung) + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "Mehr") + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false } + ) { + // --- Tour-Aktionen --- + DropdownMenuItem( + text = { Text(stringResource(R.string.tour_new)) }, + leadingIcon = { Icon(Icons.Filled.Add, null) }, + onClick = { + showOverflowMenu = false + showNewTourDialog = true + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.tour_rename)) }, + leadingIcon = { Icon(Icons.Filled.Edit, null) }, + onClick = { + showOverflowMenu = false + showRenameTourDialog = true + } + ) + if (selectedTour == Waypoint.DEFAULT_TOUR_NAME) { + // Standard-Tour kann nicht gelöscht werden – Eintrag deaktiviert + DropdownMenuItem( + text = { + Text( + stringResource(R.string.tour_delete_standard_hint), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + }, + onClick = { /* deaktiviert – Standardtour kann nicht gelöscht werden */ }, + enabled = false + ) + } else { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.tour_delete), + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Filled.Delete, + null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showOverflowMenu = false + showDeleteTourDialog = true + } + ) + } + HorizontalDivider() + // --- Import/Export --- + DropdownMenuItem( + text = { Text(stringResource(R.string.export_json)) }, + leadingIcon = { Icon(Icons.Filled.FileDownload, null) }, + onClick = { + showOverflowMenu = false + exportJsonLauncher.launch("wegpunkte.json") + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.import_json)) }, + leadingIcon = { Icon(Icons.Filled.FileUpload, null) }, + onClick = { + showOverflowMenu = false + importJsonLauncher.launch(arrayOf("application/json", "text/plain", "*/*")) + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.export_gpx)) }, + leadingIcon = { Icon(Icons.Filled.FileDownload, null) }, + onClick = { + showOverflowMenu = false + exportGpxLauncher.launch("wegpunkte.gpx") + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.import_gpx)) }, + leadingIcon = { Icon(Icons.Filled.FileUpload, null) }, + onClick = { + showOverflowMenu = false + importGpxLauncher.launch(arrayOf("application/gpx+xml", "text/xml", "application/xml", "*/*")) + } + ) + HorizontalDivider() + // Begleitmusik auch im Overflow (zusätzlich zur Karte) + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_begleitmusik)) }, + leadingIcon = { Icon(Icons.Filled.LibraryMusic, null) }, + onClick = { + showOverflowMenu = false + showBegleitmusikDialog = true + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_about)) }, + leadingIcon = { Icon(Icons.Filled.Info, null) }, + onClick = { + showOverflowMenu = false + showAboutDialog = true + } + ) + } + } + } + ) + }, + floatingActionButton = { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // GPS-Wegpunkt FAB + SmallFloatingActionButton( + onClick = onGpsButtonClicked, + containerColor = MaterialTheme.colorScheme.secondary + ) { + if (locationLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onSecondary, + strokeWidth = 2.dp + ) + } else { + Icon( + Icons.Filled.MyLocation, + contentDescription = stringResource(R.string.add_waypoint_from_location), + tint = MaterialTheme.colorScheme.onSecondary + ) + } + } + + // Neuer Wegpunkt FAB + FloatingActionButton( + onClick = { showAddDialog = true }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_waypoint)) + } + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + // ------------------------------------------------------------------- + // Tab-Leiste für Touren + // ------------------------------------------------------------------- + if (tourList.isNotEmpty()) { + // Clamp selectedTabIndex so it is always within [0, tourList.size-1]. + // indexOf returns -1 when selectedTour is not yet in tourList (async gap), + // so we coerce into the valid range to prevent ScrollableTabRow from crashing. + val tabIndex = tourList.indexOf(selectedTour) + .coerceIn(0, tourList.size - 1) + ScrollableTabRow( + selectedTabIndex = tabIndex, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary, + edgePadding = 8.dp + ) { + tourList.forEachIndexed { index, tour -> + Tab( + selected = tabIndex == index, + onClick = { viewModel.selectTour(tour) }, + text = { + Text( + text = tour, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + } + } + } + + // ------------------------------------------------------------------- + // Tour-Zähler-Karte + // ------------------------------------------------------------------- + TourCounterCard( + filteredWaypoints = filteredWaypoints, + onOpenDialog = { showTourCounterDialog = true } + ) + + // ------------------------------------------------------------------- + // Live / PTT Karte (zwischen Atmo-Bereich und Wegpunkt-Tracks) + // ------------------------------------------------------------------- + LivePttCard( + pttManager = viewModel.pttManager + ) + + // ------------------------------------------------------------------- + // Wegpunkt-Tracks Überschrift + // ------------------------------------------------------------------- + Text( + text = stringResource(R.string.waypoint_tracks_heading), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + // ------------------------------------------------------------------- + // Wegpunkt-Liste (gefiltert nach gewählter Tour) + // ------------------------------------------------------------------- + Box(modifier = Modifier.weight(1f)) { + if (filteredWaypoints.isEmpty()) { + Text( + text = if (waypoints.isEmpty()) + stringResource(R.string.no_waypoints) + else + stringResource(R.string.tour_no_waypoints), + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } else { + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + // Extra bottom padding so last card is not hidden behind player bar + bottom = 8.dp + ), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items(filteredWaypoints, key = { it.id }) { wp -> + val isThisSingleLoaded = singlePlayingWaypointId == wp.id + val isThisSinglePlaying = isThisSingleLoaded && singlePlayingActive + WaypointCard( + waypoint = wp, + onEdit = { editingWaypoint = wp }, + onDelete = { deleteCandidate = wp }, + onToggleActive = { + viewModel.upsert(wp.copy(isActive = !wp.isActive)) + }, + onPlaySingle = if (wp.soundUri.isNotBlank()) { + { viewModel.manualPlaySingle(wp) } + } else null, + isSinglePlaying = isThisSinglePlaying, + isSingleLoaded = isThisSingleLoaded + ) + } + } + } + + // Dienst-Status-Banner + if (serviceRunning) { + Surface( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "GPS-Dienst aktiv", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + + // ------------------------------------------------------------------- + // Begleitmusik Mini-Player (sichtbar unterhalb der Wegpunktliste) + // ------------------------------------------------------------------- + BegleitmusikMiniPlayer( + settings = musicSettings, + playbackState = musicPlaybackState, + errorMessage = musicError, + onPlayPause = { viewModel.toggleMusicPlayback() }, + onStop = { viewModel.stopMusicPlayback() }, + onPrevious = { viewModel.musicPrevious() }, + onNext = { viewModel.musicNext() }, + onConfigure = { showBegleitmusikDialog = true }, + onClearError = { viewModel.clearMusicError() } + ) + + // ------------------------------------------------------------------- + // Manuelle Wiedergabe-Leiste + // ------------------------------------------------------------------- + ManualPlayerBar( + hasPlayable = playableCount > 0, + // In single mode the bar also reflects play/pause state of the single item + isPlaying = if (singlePlayingWaypointId != null) singlePlayingActive else manualPlaying, + currentName = manualCurrentName, + onPrevious = { viewModel.manualPrevious() }, + // In single mode the bar's play/pause button stops single mode and goes playlist + onPlayPause = { viewModel.manualPlayPause() }, + onNext = { viewModel.manualNext() }, + isSingleMode = singlePlayingWaypointId != null + ) + } + } + + // ----------------------------------------------------------------------- + // Dialoge + // ----------------------------------------------------------------------- + + if (showAddDialog) { + val prefillLocation = currentLocation + WaypointEditDialog( + waypoint = null, + prefillLatLng = prefillLocation, + existingTours = tourList, + prefillTourName = selectedTour, + tourDefaults = tourDefaults, + onConfirm = { wp -> + viewModel.upsert(wp) + showAddDialog = false + viewModel.clearCurrentLocation() + }, + onDismiss = { + showAddDialog = false + viewModel.clearCurrentLocation() + } + ) + } + + editingWaypoint?.let { wp -> + WaypointEditDialog( + waypoint = wp, + prefillLatLng = null, + existingTours = tourList, + prefillTourName = wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME }, + onConfirm = { updated -> + viewModel.upsert(updated) + editingWaypoint = null + }, + onDismiss = { editingWaypoint = null } + ) + } + + deleteCandidate?.let { wp -> + AlertDialog( + onDismissRequest = { deleteCandidate = null }, + title = { Text(stringResource(R.string.confirm_delete_title)) }, + text = { Text(stringResource(R.string.confirm_delete_msg)) }, + confirmButton = { + TextButton(onClick = { + viewModel.delete(wp.id) + deleteCandidate = null + }) { + Text( + stringResource(R.string.delete_waypoint), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = { deleteCandidate = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showAboutDialog) { + AboutDialog(onDismiss = { showAboutDialog = false }) + } + + if (showBegleitmusikDialog) { + BegleitmusikDialog( + tourName = selectedTour, + viewModel = viewModel, + onDismiss = { showBegleitmusikDialog = false } + ) + } + + // Neue Tour Dialog + if (showNewTourDialog) { + NewTourDialog( + existingTours = tourList, + onConfirm = { tourName -> + val ok = viewModel.addTour(tourName) + // addTour returns false for blank or duplicate; dialog already validates blank, + // duplicate is now shown inside the dialog, so we only close on success. + if (ok) showNewTourDialog = false + // If !ok (e.g. race-condition duplicate), the dialog stays open for correction. + }, + onDismiss = { showNewTourDialog = false } + ) + } + + // Tour umbenennen Dialog + if (showRenameTourDialog) { + RenameTourDialog( + currentName = selectedTour, + onConfirm = { newName -> + viewModel.renameTour(selectedTour, newName) + showRenameTourDialog = false + }, + onDismiss = { showRenameTourDialog = false } + ) + } + + // Tour löschen Bestätigung + if (showDeleteTourDialog) { + AlertDialog( + onDismissRequest = { showDeleteTourDialog = false }, + title = { Text(stringResource(R.string.tour_delete_title)) }, + text = { + Text(stringResource(R.string.tour_delete_msg, selectedTour)) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteTour(selectedTour) + showDeleteTourDialog = false + }) { + Text( + stringResource(R.string.tour_delete), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteTourDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + // Tour-Zähler Dialog + if (showTourCounterDialog) { + val tourWaypoints = filteredWaypoints + val totalPlayed = tourWaypoints.sumOf { it.playCount } + TourCounterDialog( + tourName = selectedTour, + wayPointCount = tourWaypoints.size, + totalPlayed = totalPlayed, + currentDefaults = tourDefaults, + onApply = { mode, maxCount, resetCounts -> + viewModel.applyTourPlaybackMode(mode, maxCount, resetCounts) + }, + onReset = { + viewModel.resetTourPlayCounts() + }, + onDismiss = { showTourCounterDialog = false } + ) + } + + errorMessage?.let { msg -> + AlertDialog( + onDismissRequest = { viewModel.clearError() }, + title = { Text("Fehler") }, + text = { Text(msg) }, + confirmButton = { + TextButton(onClick = { viewModel.clearError() }) { + Text("OK") + } + } + ) + } +} + +// ----------------------------------------------------------------------- +// Tour-Zähler-Karte (kompaktes Panel zwischen Tab-Leiste und Wegpunkt-Liste) +// ----------------------------------------------------------------------- + +@Composable +private fun TourCounterCard( + filteredWaypoints: List, + onOpenDialog: () -> Unit +) { + if (filteredWaypoints.isEmpty()) return + + val totalPlayed = filteredWaypoints.sumOf { it.playCount } + val limitedCount = filteredWaypoints.count { it.playbackMode == PlaybackMode.LIMITED_COUNT } + val onceCount = filteredWaypoints.count { it.playbackMode == PlaybackMode.ONCE } + val everyEntryCount = filteredWaypoints.count { it.playbackMode == PlaybackMode.EVERY_ENTRY } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.55f), + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Numbers, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + Spacer(Modifier.width(6.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.tour_counter_card_title), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + // Aggregate summary line + val summaryParts = buildList { + if (totalPlayed > 0) add(stringResource(R.string.tour_counter_card_played, totalPlayed)) + if (limitedCount > 0) add(stringResource(R.string.tour_counter_card_limited, limitedCount)) + if (onceCount > 0) add(stringResource(R.string.tour_counter_card_once, onceCount)) + if (everyEntryCount > 0 && (limitedCount > 0 || onceCount > 0)) { + add(stringResource(R.string.tour_counter_card_every_entry, everyEntryCount)) + } + } + val summaryText = if (summaryParts.isEmpty()) + stringResource(R.string.tour_counter_card_all_every_entry) + else summaryParts.joinToString(" · ") + Text( + text = summaryText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.75f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TextButton( + onClick = onOpenDialog, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.tour_counter_card_configure), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } +} + +// ----------------------------------------------------------------------- +// Begleitmusik Mini-Player (kompaktes Panel unterhalb der Wegpunktliste) +// ----------------------------------------------------------------------- + +@Composable +private fun BegleitmusikMiniPlayer( + settings: TourAudioSettings, + playbackState: MusicPlaybackState, + errorMessage: String?, + onPlayPause: () -> Unit, + onStop: () -> Unit, + onPrevious: () -> Unit, + onNext: () -> Unit, + onConfigure: () -> Unit, + onClearError: () -> Unit +) { + val hasSource = when { + !settings.enabled -> false + settings.sourceType == MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty() + settings.sourceType == MusicSourceType.STREAM_URL -> !settings.streamUrl.isNullOrBlank() + else -> false + } + val isPlaying = playbackState.isPlaying + val hasContent = playbackState.hasContent + + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (settings.enabled) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 2.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Header row: icon + title + configure button + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.LibraryMusic, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (settings.enabled) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.music_card_title), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = if (settings.enabled) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 1 + ) + TextButton( + onClick = onConfigure, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + Icons.Filled.Settings, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + stringResource(R.string.music_card_configure), + style = MaterialTheme.typography.labelSmall + ) + } + } + + // When enabled and has source: Mini-Player body + if (settings.enabled && hasSource) { + // Current track title row + val titleText = when { + hasContent && playbackState.currentTitle.isNotBlank() -> + playbackState.currentTitle + hasContent -> stringResource(R.string.music_player_no_title) + else -> stringResource(R.string.music_player_idle) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Source type / position badge + val badgeText = if (playbackState.isStream) + stringResource(R.string.music_player_stream_label) + else if (playbackState.playlistTotal > 0) + stringResource( + R.string.music_player_track_of, + playbackState.playlistIndex + 1, + playbackState.playlistTotal + ) + else "" + if (badgeText.isNotBlank()) { + Surface( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.18f), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = badgeText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp) + ) + } + } + Text( + text = titleText, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + // Progress bar + time or "Live" indicator + if (hasContent) { + if (!playbackState.isStream && playbackState.durationMs > 0) { + val progress = (playbackState.positionMs.toFloat() / + playbackState.durationMs.toFloat()).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(3.dp), + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.25f) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatMs(playbackState.positionMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + Text( + text = formatMs(playbackState.durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + ) + } + } else if (playbackState.isStream && isPlaying) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.error, + shape = MaterialTheme.shapes.extraSmall, + modifier = Modifier.size(6.dp) + ) {} + Text( + text = stringResource(R.string.music_player_live), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + // Next track line (only local playlist with >1 track) + if (!playbackState.isStream && playbackState.nextTitle.isNotBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.music_player_next_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.6f) + ) + Text( + text = playbackState.nextTitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + // Playback controls row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Previous (only local playlist) + IconButton( + onClick = onPrevious, + enabled = playbackState.supportsSkip, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.music_player_previous), + modifier = Modifier.size(20.dp), + tint = if (playbackState.supportsSkip) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.3f) + ) + } + + // Play / Pause + FilledIconButton( + onClick = onPlayPause, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary + ), + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = stringResource(R.string.music_player_play_pause), + modifier = Modifier.size(22.dp) + ) + } + + // Next (only local playlist) + IconButton( + onClick = onNext, + enabled = playbackState.supportsSkip, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.music_player_next), + modifier = Modifier.size(20.dp), + tint = if (playbackState.supportsSkip) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.3f) + ) + } + + // Stop + if (hasContent) { + IconButton( + onClick = onStop, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.music_player_stop), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + + Spacer(Modifier.weight(1f)) + + // Behavior + autostart badges + val behaviorLabel = when (settings.behavior) { + WaypointMusicBehavior.PAUSE_RESUME -> + stringResource(R.string.music_card_behavior_pause_resume) + WaypointMusicBehavior.FADE_OUT_IN -> + stringResource(R.string.music_card_behavior_fade) + WaypointMusicBehavior.DUCK_UNDERLAY -> + stringResource(R.string.music_card_behavior_duck) + WaypointMusicBehavior.CONTINUE_UNDERLAY -> + stringResource(R.string.music_card_behavior_continue) + } + Surface( + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = behaviorLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + maxLines = 1 + ) + } + if (settings.autoStartAfterWaypoint) { + Surface( + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = stringResource(R.string.music_card_autostart_active), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + maxLines = 1 + ) + } + } + } + + } else { + // Disabled / no source status line + val statusText = when { + !settings.enabled -> stringResource(R.string.music_card_disabled) + else -> stringResource(R.string.music_card_no_source) + } + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Error message + if (errorMessage != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + Icons.Filled.Warning, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = onClearError, + modifier = Modifier.size(20.dp) + ) { + Icon( + Icons.Filled.Close, + contentDescription = "Fehler schlie\u00dfen", + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } +} + +/** Formatiert Millisekunden als M:SS */ +private fun formatMs(ms: Long): String { + val totalSec = (ms / 1000).coerceAtLeast(0L) + val min = totalSec / 60 + val sec = totalSec % 60 + return "%d:%02d".format(min, sec) +} +// ----------------------------------------------------------------------- +// Manuelle Wiedergabe-Leiste +// ----------------------------------------------------------------------- + +@Composable +private fun ManualPlayerBar( + hasPlayable: Boolean, + isPlaying: Boolean, + currentName: String?, + onPrevious: () -> Unit, + onPlayPause: () -> Unit, + onNext: () -> Unit, + /** true if the player is currently in single-item mode (locks Prev/Next) */ + isSingleMode: Boolean = false +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 4.dp + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) { + // Label: mode indicator + currently playing waypoint name or disabled hint + val labelText = when { + !hasPlayable -> stringResource(R.string.manual_no_audio) + isSingleMode && currentName != null -> + stringResource(R.string.waypoint_single_now_playing, currentName) + currentName != null -> stringResource(R.string.manual_now_playing, currentName) + else -> stringResource(R.string.manual_player_label) + } + Text( + text = labelText, + style = MaterialTheme.typography.labelMedium, + color = if (hasPlayable) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) + + // In single mode: show an informational badge that prev/next are disabled + if (isSingleMode && hasPlayable) { + Text( + text = stringResource(R.string.waypoint_single_mode_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp) + ) + } + + // Prev/Next are disabled in single mode (single plays only 1 file) + val canSkip = hasPlayable && !isSingleMode + + // Control row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // Previous + IconButton( + onClick = onPrevious, + enabled = canSkip + ) { + Icon( + imageVector = Icons.Filled.SkipPrevious, + contentDescription = stringResource(R.string.manual_previous), + tint = if (canSkip) + MaterialTheme.colorScheme.onSurfaceVariant + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + } + + Spacer(Modifier.width(8.dp)) + + // Play / Pause – filled button for prominence + // In single mode this button resumes/pauses the single-item via the bar too + FilledIconButton( + onClick = onPlayPause, + enabled = hasPlayable, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ), + modifier = Modifier.size(44.dp) + ) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = stringResource(R.string.manual_play_pause) + ) + } + + Spacer(Modifier.width(8.dp)) + + // Next + IconButton( + onClick = onNext, + enabled = canSkip + ) { + Icon( + imageVector = Icons.Filled.SkipNext, + contentDescription = stringResource(R.string.manual_next), + tint = if (canSkip) + MaterialTheme.colorScheme.onSurfaceVariant + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + } + } + } + } +} + +// ----------------------------------------------------------------------- +// Neue Tour Dialog +// ----------------------------------------------------------------------- + +@Composable +private fun NewTourDialog( + existingTours: List = emptyList(), + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var tourName by remember { mutableStateOf("") } + // 0 = no error, 1 = blank, 2 = duplicate + var nameErrorKind by remember { mutableStateOf(0) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.tour_new)) }, + text = { + OutlinedTextField( + value = tourName, + onValueChange = { tourName = it; nameErrorKind = 0 }, + label = { Text(stringResource(R.string.tour_name_label)) }, + isError = nameErrorKind != 0, + supportingText = when (nameErrorKind) { + 1 -> {{ Text(stringResource(R.string.tour_name_empty_error)) }} + 2 -> {{ Text(stringResource(R.string.tour_name_duplicate_error)) }} + else -> null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + val trimmed = tourName.trim() + when { + trimmed.isBlank() -> nameErrorKind = 1 + trimmed in existingTours -> nameErrorKind = 2 + else -> onConfirm(trimmed) + } + }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +// ----------------------------------------------------------------------- +// Tour umbenennen Dialog +// ----------------------------------------------------------------------- + +@Composable +private fun RenameTourDialog( + currentName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var newName by remember { mutableStateOf(currentName) } + var nameError by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.tour_rename)) }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it; nameError = false }, + label = { Text(stringResource(R.string.tour_name_label)) }, + isError = nameError, + supportingText = if (nameError) {{ Text(stringResource(R.string.tour_name_empty_error)) }} else null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { + if (newName.isBlank()) { + nameError = true + } else { + onConfirm(newName.trim()) + } + }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +// ----------------------------------------------------------------------- +// WaypointCard +// ----------------------------------------------------------------------- + +@Composable +private fun WaypointCard( + waypoint: Waypoint, + onEdit: () -> Unit, + onDelete: () -> Unit, + onToggleActive: () -> Unit, + /** null = no single-play callback available (no soundUri). Called when play/pause tapped. */ + onPlaySingle: (() -> Unit)? = null, + /** true if THIS waypoint is currently the active single-play item AND playing (not paused). */ + isSinglePlaying: Boolean = false, + /** true if THIS waypoint is loaded in single mode (playing OR paused). */ + isSingleLoaded: Boolean = false +) { + // Highlight the card border when this waypoint is active in single mode + val cardBorder = if (isSingleLoaded) + CardDefaults.outlinedCardBorder() + else + null + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = when { + isSingleLoaded -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + waypoint.isActive -> MaterialTheme.colorScheme.surface + else -> MaterialTheme.colorScheme.surfaceVariant + } + ), + border = cardBorder, + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSingleLoaded) 3.dp else 1.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Switch( + checked = waypoint.isActive, + onCheckedChange = { onToggleActive() }, + modifier = Modifier.padding(end = 8.dp) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = waypoint.name.ifBlank { "Unbenannt" }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "%.6f, %.6f · %dm".format( + Locale.US, waypoint.latitude, waypoint.longitude, waypoint.radiusMeters.toInt() + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + if (waypoint.soundName.isNotBlank()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.MusicNote, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = if (isSingleLoaded) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(4.dp)) + Text( + text = waypoint.soundName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + // Abspielregel-Zusammenfassung + val ruleSummary = buildPlaybackRuleSummary(waypoint) + if (ruleSummary.isNotBlank()) { + Text( + text = ruleSummary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Per-waypoint single-play button + val hasAudio = waypoint.soundUri.isNotBlank() + if (hasAudio && onPlaySingle != null) { + // Button visible when audio is assigned + IconButton( + onClick = onPlaySingle, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isSinglePlaying) Icons.Filled.Pause else Icons.Filled.PlayCircle, + contentDescription = if (isSinglePlaying) + stringResource(R.string.waypoint_pause_single) + else + stringResource(R.string.waypoint_play_single), + tint = if (isSingleLoaded) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp) + ) + } + } else if (!hasAudio) { + // No audio: show disabled icon with semantic description + IconButton( + onClick = {}, + enabled = false, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.VolumeOff, + contentDescription = stringResource(R.string.waypoint_no_audio_hint), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + ) + } + } + + IconButton( + onClick = onEdit, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Filled.Edit, + contentDescription = stringResource(R.string.edit_waypoint), + modifier = Modifier.size(20.dp) + ) + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.delete_waypoint), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +/** + * Erzeugt eine kompakte Zusammenfassung der Abspielregeln für die Listenansicht. + */ +private fun buildPlaybackRuleSummary(waypoint: Waypoint): String { + val parts = mutableListOf() + + val modePart = when (waypoint.playbackMode) { + de.waypointaudio.data.PlaybackMode.EVERY_ENTRY -> { + if (waypoint.scheduleEnabled) "bei jedem Betreten" else "" + } + de.waypointaudio.data.PlaybackMode.ONCE -> { + val played = if (waypoint.playCount > 0) " · ${waypoint.playCount}/1 gespielt" else "" + "nur einmal$played" + } + de.waypointaudio.data.PlaybackMode.LIMITED_COUNT -> { + val max = waypoint.maxPlayCount ?: 1 + "max. ${max}× · ${waypoint.playCount}/${max} gespielt" + } + } + if (modePart.isNotBlank()) parts.add(modePart) + + if (waypoint.scheduleEnabled) parts.add("Zeitplan aktiv") + + if (parts.isEmpty()) return "" + return "Abspielen: " + parts.joinToString(" · ") +} diff --git a/app/src/main/kotlin/de/waypointaudio/ui/theme/Theme.kt b/app/src/main/kotlin/de/waypointaudio/ui/theme/Theme.kt new file mode 100644 index 0000000..b68098f --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt new file mode 100644 index 0000000..93b6422 --- /dev/null +++ b/app/src/main/kotlin/de/waypointaudio/viewmodel/WaypointViewModel.kt @@ -0,0 +1,1042 @@ +package de.waypointaudio.viewmodel + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Application +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.Uri +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import de.waypointaudio.data.GpsTrackPoint +import de.waypointaudio.data.PlaybackMode +import de.waypointaudio.data.TourAudioSettings +import de.waypointaudio.data.TourMusicStore +import de.waypointaudio.data.TourPlaybackDefaults +import de.waypointaudio.data.Waypoint +import de.waypointaudio.io.ImportExportManager +import de.waypointaudio.repository.WaypointRepository +import de.waypointaudio.service.BackgroundMusicManager +import de.waypointaudio.service.LivePttManager +import de.waypointaudio.service.ManualAudioPlayer +import de.waypointaudio.service.TrackRecordingManager +import de.waypointaudio.service.WaypointLocationService +import de.waypointaudio.service.MusicPlaybackState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeout + +/** + * ViewModel für die Haupt-UI. + * Hält die Wegpunktliste, Tourliste, den Dienst-Status und verwaltet + * Import/Export sowie die GPS-Position für neue Wegpunkte. + * + * Neu: Tour-weite Abspiel-Vorgaben (TourPlaybackDefaults) – neue Wegpunkte + * erben automatisch playbackMode und maxPlayCount der aktuellen Tour. + * Neu: GPS-Track-Aufzeichnung (In-Memory + persistiert per Tour). + */ +class WaypointViewModel(application: Application) : AndroidViewModel(application) { + + private val repository = WaypointRepository(application) + private val fusedLocationClient = + LocationServices.getFusedLocationProviderClient(application) + private val musicStore = TourMusicStore(application) + + /** Begleitmusik-Manager (App-Singleton). Zugänglich für UI-Dialog. */ + val musicManager = BackgroundMusicManager.getInstance(application) + + /** Live/PTT Manager (App-Singleton). Zugänglich für UI. */ + val pttManager = LivePttManager.getInstance(application) + + /** Dedicated player for manual (UI-driven) audio playback. */ + private val manualPlayer = ManualAudioPlayer() + + // ─── Begleitmusik State ─────────────────────────────────────────────────── + + /** True wenn Begleitmusik für die aktuelle Tour aktiviert und am Spielen ist. */ + private val _musicPlaying = MutableStateFlow(false) + val musicPlaying: StateFlow = _musicPlaying.asStateFlow() + + /** Aktueller Wiedergabe-Zustand des Begleitmusik-Players (für Mini-Player UI). */ + val musicPlaybackState: StateFlow = musicManager.player.playbackState + + /** Aktuell geladene Begleitmusik-Einstellungen (für die UI). */ + private val _musicSettings = MutableStateFlow(TourAudioSettings()) + val musicSettings: StateFlow = _musicSettings.asStateFlow() + + /** + * Fehlermeldung bei Begleitmusik (z. B. Stream-Fehler, keine Quelle konfiguriert). + * null = kein Fehler. + */ + private val _musicError = MutableStateFlow(null) + val musicError: StateFlow = _musicError.asStateFlow() + + /** Aktuelle Wegpunktliste (alle Touren). */ + private val _waypoints = MutableStateFlow>(emptyList()) + val waypoints: StateFlow> = _waypoints.asStateFlow() + + /** + * Geordnete Tourliste (persistent, inkl. leerer Touren). + * Enthält mindestens [Waypoint.DEFAULT_TOUR_NAME]. + */ + private val _tourList = MutableStateFlow>(listOf(Waypoint.DEFAULT_TOUR_NAME)) + val tourList: StateFlow> = _tourList.asStateFlow() + + /** + * Set of tour names that have been deleted optimistically. + * The combine collector must not re-add these from waypoint-derived tours. + */ + private val _deletedTours = mutableSetOf() + + /** + * Aktuell ausgewählte Tour (für Tabs und Filter). + */ + private val _selectedTour = MutableStateFlow(Waypoint.DEFAULT_TOUR_NAME) + val selectedTour: StateFlow = _selectedTour.asStateFlow() + + /** Ob der Hintergrund-Dienst läuft. */ + private val _serviceRunning = MutableStateFlow(false) + val serviceRunning: StateFlow = _serviceRunning.asStateFlow() + + /** + * Einmalige Fehlermeldung für Import/Export-Fehler. + */ + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + /** + * Letzte bekannte GPS-Position (lat, lon) für den "Aktuellen Standort"-Button. + */ + private val _currentLocation = MutableStateFlow?>(null) + val currentLocation: StateFlow?> = _currentLocation.asStateFlow() + + /** true während der GPS-Positionsabfrage läuft. */ + private val _locationLoading = MutableStateFlow(false) + val locationLoading: StateFlow = _locationLoading.asStateFlow() + + // ─── Manual Playback State ──────────────────────────────────────────────── + + /** Index (in the playable playlist) of the waypoint currently selected for manual play. */ + private val _manualIndex = MutableStateFlow(-1) + val manualIndex: StateFlow = _manualIndex.asStateFlow() + + /** Whether the manual player is actively playing right now. */ + private val _manualPlaying = MutableStateFlow(false) + val manualPlaying: StateFlow = _manualPlaying.asStateFlow() + + /** Name of the waypoint currently loaded in the manual player (null = nothing loaded). */ + private val _manualCurrentName = MutableStateFlow(null) + val manualCurrentName: StateFlow = _manualCurrentName.asStateFlow() + + /** + * ID of the waypoint currently playing (or paused) in single-item mode. + * null means no single-item playback is active (either nothing playing or playlist mode). + * UI uses this to show the correct active/pause state on each card's play button. + */ + private val _singlePlayingWaypointId = MutableStateFlow(null) + val singlePlayingWaypointId: StateFlow = _singlePlayingWaypointId.asStateFlow() + + /** + * True when the player is in single-item mode AND actively playing (not paused). + * Used by the card to decide whether to show a Pause or Play icon. + */ + private val _singlePlayingActive = MutableStateFlow(false) + val singlePlayingActive: StateFlow = _singlePlayingActive.asStateFlow() + + // ─── Tour-weite Abspiel-Vorgaben ────────────────────────────────────────── + + /** + * Aktuell geladene Abspiel-Vorgaben für die ausgewählte Tour. + * Wird beim Tour-Wechsel und nach dem Speichern neuer Vorgaben aktualisiert. + */ + private val _tourDefaults = MutableStateFlow(TourPlaybackDefaults()) + val tourDefaults: StateFlow = _tourDefaults.asStateFlow() + + // ─── GPS-Track-Aufzeichnung (delegiert an TrackRecordingManager) ────────── + + /** Aktuell aufgezeichnete Track-Punkte – kommt direkt vom TrackRecordingManager. */ + val currentTrack: StateFlow> = TrackRecordingManager.currentTrack + + /** Ob gerade ein Track aufgezeichnet wird – kommt direkt vom TrackRecordingManager. */ + val isRecording: StateFlow = TrackRecordingManager.isRecording + + /** + * Persistierter Track der aktuellen Tour (aus DataStore geladen). + */ + private val _persistedTrack = MutableStateFlow>(emptyList()) + val persistedTrack: StateFlow> = _persistedTrack.asStateFlow() + + // ───────────────────────────────────────────────────────────────────────── + + init { + viewModelScope.launch { + repository.waypoints.collect { list -> + _waypoints.value = list + } + } + viewModelScope.launch { + // Kombiniere persistent gespeicherte Tourliste mit Touren aus Wegpunkten + combine(repository.tours, repository.waypoints) { storedTours, wps -> + val wpTours = wps.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct() + val merged = (storedTours + wpTours).distinct() + if (merged.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else merged + }.collect { merged -> + // Merge the incoming authoritative list with any optimistic entries that + // were added to _tourList before persistence completed. This prevents the + // combine from briefly dropping a just-created tour and resetting the + // selected tour back to an older value. + // Exclude tours that were optimistically deleted so they are not re-added. + val optimistic = _tourList.value + val combined = (merged + optimistic) + .distinct() + .filter { it !in _deletedTours } + .let { if (it.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else it } + _tourList.value = combined + val current = _selectedTour.value + if (current.isNotBlank() && current !in combined) { + _selectedTour.value = combined.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME + } + } + } + // Begleitmusik-Einstellungen für die initiale Tour laden + viewModelScope.launch { + loadMusicSettingsForCurrentTour() + } + // Tour-Vorgaben für Starttour laden + viewModelScope.launch { + loadTourDefaultsForCurrentTour() + } + // Persistierten Track für Starttour laden + viewModelScope.launch { + loadPersistedTrackForCurrentTour() + } + } + + // --------------------------------------------------------------------------- + // CRUD + // --------------------------------------------------------------------------- + + /** Wegpunkt hinzufügen oder aktualisieren. */ + fun upsert(waypoint: Waypoint) { + viewModelScope.launch { + val safe = waypoint.copy( + tourName = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } + ) + repository.upsert(safe) + val currentTours = _tourList.value + if (safe.tourName !in currentTours) { + val updated = currentTours + safe.tourName + repository.saveTours(updated) + } + } + } + + /** Wegpunkt löschen. */ + fun delete(id: String) { + viewModelScope.launch { repository.delete(id) } + } + + // --------------------------------------------------------------------------- + // Tour-Verwaltung + // --------------------------------------------------------------------------- + + fun selectTour(tourName: String) { + // Guard: only select tours that are known, or fall back to first available. + val knownTours = _tourList.value + val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName + else knownTours.first() + _selectedTour.value = safeTarget + // Reset manual player when tour changes + manualPlayer.stop() + _manualPlaying.value = false + _manualCurrentName.value = null + _manualIndex.value = -1 + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + // Musik für neue Tour laden + musicManager.loadTour(safeTarget) + viewModelScope.launch { + loadMusicSettingsForCurrentTour() + loadTourDefaultsForCurrentTour() + loadPersistedTrackForCurrentTour() + } + // GPS-Track beim Tour-Wechsel stoppen + if (TrackRecordingManager.isRecording.value) stopTrackRecording() + TrackRecordingManager.clearTrack() + } + + fun addTour(tourName: String): Boolean { + val trimmed = tourName.trim() + if (trimmed.isBlank()) return false + val current = _tourList.value + if (trimmed in current) return false + // Optimistically update _tourList immediately so the UI sees a consistent state + // before the async DataStore write + combine-collector cycle completes. + val updated = current + trimmed + _tourList.value = updated + // Select the new tour right away (same thread, before any recomposition). + // Use direct assignment instead of selectTour() to avoid triggering + // music-loading coroutine before the tour list is fully stable. + _selectedTour.value = trimmed + // Reset manual player state + manualPlayer.stop() + _manualPlaying.value = false + _manualCurrentName.value = null + _manualIndex.value = -1 + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + viewModelScope.launch { + runCatching { + repository.saveTours(updated) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tourliste", e) + } + // Load music settings safely after persistence + runCatching { + musicManager.loadTour(trimmed) + loadMusicSettingsForCurrentTour() + loadTourDefaultsForCurrentTour() + loadPersistedTrackForCurrentTour() + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Musik für neue Tour '$trimmed'", e) + } + } + return true + } + + fun renameTour(oldName: String, newName: String): Boolean { + val trimmed = newName.trim() + if (trimmed.isBlank()) return false + if (trimmed == oldName) return true + val current = _tourList.value + if (trimmed in current) return false + viewModelScope.launch { + val updated = current.map { if (it == oldName) trimmed else it } + repository.saveTours(updated) + val updatedWps = _waypoints.value.map { + if (it.tourName == oldName) it.copy(tourName = trimmed) else it + } + repository.saveAll(updatedWps) + if (_selectedTour.value == oldName) _selectedTour.value = trimmed + } + return true + } + + fun deleteTour(tourName: String): Boolean { + if (tourName == Waypoint.DEFAULT_TOUR_NAME) return false + + // --- Optimistic update (immediate, before any async persistence) --- + // Mark as deleted so the combine collector does not re-add it. + _deletedTours.add(tourName) + + val current = _tourList.value.toMutableList() + current.remove(tourName) + if (current.isEmpty()) current.add(Waypoint.DEFAULT_TOUR_NAME) + _tourList.value = current + + // Switch selected tour immediately so ScrollableTabRow index stays valid. + val nextTour = current.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME + if (_selectedTour.value == tourName) { + // Reset manual player state + manualPlayer.stop() + _manualPlaying.value = false + _manualCurrentName.value = null + _manualIndex.value = -1 + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + _selectedTour.value = nextTour + } + + // --- Async persistence --- + viewModelScope.launch { + runCatching { + repository.saveTours(current) + val updatedWps = _waypoints.value.map { + if (it.tourName == tourName) it.copy(tourName = Waypoint.DEFAULT_TOUR_NAME) else it + } + repository.saveAll(updatedWps) + // Clear GPS track for the deleted tour + repository.clearGpsTrack(tourName) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim L\u00f6schen der Tour '$tourName'", e) + } + // Load settings for the now-selected tour + runCatching { + musicManager.loadTour(nextTour, force = true) + loadMusicSettingsForCurrentTour() + loadTourDefaultsForCurrentTour() + loadPersistedTrackForCurrentTour() + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Laden nach Tour-L\u00f6schen", e) + } + } + return true + } + + // --------------------------------------------------------------------------- + // Dienst-Steuerung + // --------------------------------------------------------------------------- + + fun startService() { + val ctx = getApplication() + val intent = Intent(ctx, WaypointLocationService::class.java).apply { + action = WaypointLocationService.ACTION_START + } + ctx.startForegroundService(intent) + _serviceRunning.value = true + } + + fun stopService() { + val ctx = getApplication() + val intent = Intent(ctx, WaypointLocationService::class.java).apply { + action = WaypointLocationService.ACTION_STOP + } + ctx.startService(intent) + _serviceRunning.value = false + } + + fun setServiceRunning(running: Boolean) { + _serviceRunning.value = running + } + + // --------------------------------------------------------------------------- + // Import / Export + // --------------------------------------------------------------------------- + + fun exportJson(uri: Uri) { + viewModelScope.launch { + val error = ImportExportManager.exportJson(getApplication(), uri, _waypoints.value) + if (error != null) _errorMessage.value = error + } + } + + fun importJson(uri: Uri, merge: Boolean = false) { + viewModelScope.launch { + val (imported, error) = ImportExportManager.importJson(getApplication(), uri) + if (error != null) { + _errorMessage.value = error + return@launch + } + if (merge) { + val existing = _waypoints.value.associateBy { it.id }.toMutableMap() + imported.forEach { existing[it.id] = it } + repository.saveAll(existing.values.toList()) + } else { + repository.saveAll(imported) + } + val importedTours = imported.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct() + val currentTours = _tourList.value + val merged = (currentTours + importedTours).distinct() + if (merged != currentTours) repository.saveTours(merged) + } + } + + fun exportGpx(uri: Uri) { + viewModelScope.launch { + val error = ImportExportManager.exportGpx(getApplication(), uri, _waypoints.value) + if (error != null) _errorMessage.value = error + } + } + + fun importGpx(uri: Uri, merge: Boolean = false) { + viewModelScope.launch { + val (imported, error) = ImportExportManager.importGpx(getApplication(), uri) + if (error != null) { + _errorMessage.value = error + return@launch + } + if (merge) { + val existing = _waypoints.value.associateBy { it.id }.toMutableMap() + imported.forEach { existing[it.id] = it } + repository.saveAll(existing.values.toList()) + } else { + repository.saveAll(imported) + } + val importedTours = imported.map { it.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } }.distinct() + val currentTours = _tourList.value + val merged = (currentTours + importedTours).distinct() + if (merged != currentTours) repository.saveTours(merged) + } + } + + fun clearError() { + _errorMessage.value = null + } + + // --------------------------------------------------------------------------- + // GPS-Position für neuen Wegpunkt + // --------------------------------------------------------------------------- + + @SuppressLint("MissingPermission") + fun fetchCurrentLocation() { + viewModelScope.launch { + _locationLoading.value = true + _currentLocation.value = null + + val ctx = getApplication() + + val fineGranted = ContextCompat.checkSelfPermission( + ctx, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val coarseGranted = ContextCompat.checkSelfPermission( + ctx, Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + if (!fineGranted && !coarseGranted) { + _errorMessage.value = + "Standortberechtigung fehlt. Bitte erteilen Sie die Berechtigung \"Genauer Standort\" in den App-Einstellungen." + _locationLoading.value = false + return@launch + } + + val locationManager = ctx.getSystemService(android.content.Context.LOCATION_SERVICE) + as LocationManager + val gpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + val networkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + + runCatching { + var location: android.location.Location? = null + + if (gpsEnabled || networkEnabled) { + val cts = CancellationTokenSource() + val priority = if (fineGranted) + Priority.PRIORITY_HIGH_ACCURACY + else + Priority.PRIORITY_BALANCED_POWER_ACCURACY + + runCatching { + withTimeout(15_000L) { + location = fusedLocationClient + .getCurrentLocation(priority, cts.token) + .await() + } + }.onFailure { + location = null + } + } + + if (location == null) { + runCatching { + location = fusedLocationClient.lastLocation.await() + } + } + + if (location != null) { + _currentLocation.value = Pair(location!!.latitude, location!!.longitude) + } else if (!gpsEnabled && !networkEnabled) { + _errorMessage.value = + "GPS ist deaktiviert. Bitte aktivieren Sie den Standortdienst " + + "in den Systemeinstellungen und versuchen Sie es erneut." + } else { + _errorMessage.value = + "GPS-Position konnte nicht ermittelt werden. " + + "Bitte stellen Sie sicher, dass GPS aktiviert ist und " + + "versuchen Sie es im Freien erneut." + } + }.onFailure { e -> + _errorMessage.value = "Standortfehler: ${e.localizedMessage ?: e.javaClass.simpleName}" + } + + _locationLoading.value = false + } + } + + fun clearCurrentLocation() { + _currentLocation.value = null + } + + // --------------------------------------------------------------------------- + // Manuelle Wiedergabe + // --------------------------------------------------------------------------- + + /** + * Returns the playlist of waypoints eligible for manual playback: + * active waypoints in the selected tour that have a soundUri. + */ + fun manualPlaylist(): List { + val tour = _selectedTour.value + return _waypoints.value.filter { wp -> + wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour && + wp.isActive && + wp.soundUri.isNotBlank() + } + } + + /** + * Toggle play/pause for the global manual player bar (playlist mode). + * - If single-item mode is active, it is stopped first and playlist mode takes over. + * - If nothing is selected, starts the first playable waypoint. + * - If playing → pauses. + * - If paused → resumes. + */ + fun manualPlayPause() { + val ctx = getApplication() + val playlist = manualPlaylist() + if (playlist.isEmpty()) return + + // PTT-Sperre: Wenn PTT aktiv ist, keine manuelle Wiedergabe starten + if (pttManager.pttActive.value) { + _errorMessage.value = "PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt." + return + } + + // If single-item mode is active, stop it and transition to playlist mode + if (manualPlayer.isSingleMode) { + manualPlayer.stop() + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + } + + if (manualPlayer.isPlaying) { + manualPlayer.pause() + _manualPlaying.value = false + // Begleitmusik nach Pause des Wegpunkt-Audios wiederherstellen + musicManager.afterWaypointAudio() + return + } + + val idx = _manualIndex.value.coerceIn(0, playlist.size - 1) + val wp = playlist[idx] + _manualIndex.value = idx + _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${idx + 1}" } + _manualPlaying.value = true + + // Begleitmusik vor Wegpunkt-Audio vorbereiten + musicManager.beforeWaypointAudio() + manualPlayer.play(ctx, wp.soundUri) { + // Playback completed naturally + _manualPlaying.value = false + musicManager.afterWaypointAudio() + } + } + + /** Skip to previous playable waypoint and start playback (playlist mode). */ + fun manualPrevious() { + val ctx = getApplication() + val playlist = manualPlaylist() + if (playlist.isEmpty()) return + + // Exit single mode before switching to playlist navigation + if (manualPlayer.isSingleMode) { + manualPlayer.stop() + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + } + + val newIdx = if (_manualIndex.value <= 0) playlist.size - 1 + else _manualIndex.value - 1 + _manualIndex.value = newIdx + val wp = playlist[newIdx] + _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${newIdx + 1}" } + _manualPlaying.value = true + + musicManager.beforeWaypointAudio() + manualPlayer.play(ctx, wp.soundUri) { + _manualPlaying.value = false + musicManager.afterWaypointAudio() + } + } + + /** Skip to next playable waypoint and start playback (playlist mode). */ + fun manualNext() { + val ctx = getApplication() + val playlist = manualPlaylist() + if (playlist.isEmpty()) return + + // Exit single mode before switching to playlist navigation + if (manualPlayer.isSingleMode) { + manualPlayer.stop() + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + } + + val newIdx = if (_manualIndex.value >= playlist.size - 1) 0 + else _manualIndex.value + 1 + _manualIndex.value = newIdx + val wp = playlist[newIdx] + _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${newIdx + 1}" } + _manualPlaying.value = true + + musicManager.beforeWaypointAudio() + manualPlayer.play(ctx, wp.soundUri) { + _manualPlaying.value = false + musicManager.afterWaypointAudio() + } + } + + /** Play a specific waypoint by its position in the current playlist. */ + fun manualPlayAt(index: Int) { + val ctx = getApplication() + val playlist = manualPlaylist() + if (index !in playlist.indices) return + + val wp = playlist[index] + _manualIndex.value = index + _manualCurrentName.value = wp.name.ifBlank { "Wegpunkt ${index + 1}" } + _manualPlaying.value = true + + musicManager.beforeWaypointAudio() + manualPlayer.play(ctx, wp.soundUri) { + _manualPlaying.value = false + musicManager.afterWaypointAudio() + } + } + + /** Stop manual playback completely (both playlist and single mode). */ + fun manualStop() { + manualPlayer.stop() + _manualPlaying.value = false + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + musicManager.afterWaypointAudio() + } + + // --------------------------------------------------------------------------- + // Per-Waypoint Single-Item Playback + // --------------------------------------------------------------------------- + + /** + * Play (or pause) a single waypoint file triggered from its card. + * + * Behaviour: + * - If global playlist playback is active, it is stopped first. + * - If another waypoint's single play is active, it is stopped and the new one starts. + * - If the same waypoint is already playing in single mode → pauses. + * - If the same waypoint is paused in single mode → resumes. + * - On natural completion, single-mode state is cleared; no next item is queued. + * - Atmo before/after hooks fire around the single file, just like playlist mode. + */ + fun manualPlaySingle(waypoint: Waypoint) { + val ctx = getApplication() + val uri = waypoint.soundUri + if (uri.isBlank()) return + + // PTT-Sperre + if (pttManager.pttActive.value) { + _errorMessage.value = "PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt." + return + } + + val waypointId = waypoint.id + val displayName = waypoint.name.ifBlank { "Wegpunkt" } + + // If global playlist mode is playing, stop it cleanly + if (_manualPlaying.value && !manualPlayer.isSingleMode) { + manualPlayer.stop() + _manualPlaying.value = false + musicManager.afterWaypointAudio() + } + + // Determine toggle behaviour for the same waypoint + val sameSingleActive = manualPlayer.isSingleMode && + manualPlayer.singleWaypointId == waypointId + + if (sameSingleActive) { + // Toggle pause/resume via playSingle (it handles the toggle internally) + if (manualPlayer.isPlaying) { + // Pausing: call playSingle which detects same-waypoint-playing → pauses + manualPlayer.playSingle(ctx, waypointId, uri) { + // This callback is only called on natural completion; not on pause + } + _singlePlayingActive.value = false + musicManager.afterWaypointAudio() + } else { + // Resuming + manualPlayer.playSingle(ctx, waypointId, uri) { + _singlePlayingActive.value = false + _singlePlayingWaypointId.value = null + _manualCurrentName.value = null + musicManager.afterWaypointAudio() + } + _singlePlayingActive.value = true + musicManager.beforeWaypointAudio() + } + return + } + + // New waypoint single play — Atmo before hook first + musicManager.beforeWaypointAudio() + + _singlePlayingWaypointId.value = waypointId + _singlePlayingActive.value = true + _manualCurrentName.value = displayName + + manualPlayer.playSingle(ctx, waypointId, uri) { + // Natural completion — clear state, no auto-advance + _singlePlayingWaypointId.value = null + _singlePlayingActive.value = false + _manualCurrentName.value = null + musicManager.afterWaypointAudio() + } + } + + // --------------------------------------------------------------------------- + // Begleitmusik + // --------------------------------------------------------------------------- + + /** Lädt Begleitmusik-Einstellungen für die aktuelle Tour. */ + private suspend fun loadMusicSettingsForCurrentTour() { + runCatching { + val settings = musicStore.settingsForTour(_selectedTour.value).first() + _musicSettings.value = settings + _musicError.value = null + // force=true, damit nach Tour-Wechsel und nach Speichern immer aktuell geladen wird + musicManager.loadTour(_selectedTour.value, force = true) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Musik-Einstellungen", e) + } + } + + // --------------------------------------------------------------------------- + // Tour-Zähler (Massen-Aktionen) + // --------------------------------------------------------------------------- + + /** + * Setzt den Abspiel-Zähler (playCount) aller Wegpunkte der aktuellen Tour auf 0 zurück. + * Zeigt eine Bestätigung via [errorMessage] (als Erfolgsmeldung). + */ + fun resetTourPlayCounts() { + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { + val count = repository.resetPlayCountsForTour(tour) + if (count > 0) { + // Optimistically update in-memory list + _waypoints.value = _waypoints.value.map { wp -> + if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour) + wp.copy(playCount = 0) + else wp + } + } + }.onFailure { e -> + _errorMessage.value = "Fehler beim Zurücksetzen der Zähler: ${e.localizedMessage}" + } + } + } + + /** + * Wendet einen Tour-weiten Abspiel-Modus auf alle Wegpunkte der aktuellen Tour an + * UND speichert die Einstellung als Tour-Vorgabe für neue Wegpunkte. + * + * @param mode Gewünschter Abspiel-Modus + * @param maxPlayCount Maximale Anzahl (nur für LIMITED_COUNT, sonst ignoriert) + * @param resetCounts true = playCount aller Wegpunkte auf 0 setzen + */ + fun applyTourPlaybackMode(mode: PlaybackMode, maxPlayCount: Int?, resetCounts: Boolean) { + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { + repository.applyPlaybackModeForTour(tour, mode, maxPlayCount, resetCounts) + // Optimistically update in-memory list so UI updates immediately + _waypoints.value = _waypoints.value.map { wp -> + if (wp.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == tour) { + wp.copy( + playbackMode = mode, + maxPlayCount = if (mode == PlaybackMode.LIMITED_COUNT) maxPlayCount else null, + playCount = if (resetCounts) 0 else wp.playCount + ) + } else wp + } + // ★ Neu: Vorgaben für neue Wegpunkte speichern + val defaults = TourPlaybackDefaults( + tourName = tour, + playbackMode = mode, + maxPlayCount = maxPlayCount ?: 3 + ) + repository.saveTourDefaults(tour, defaults) + _tourDefaults.value = defaults + }.onFailure { e -> + _errorMessage.value = "Fehler beim Anwenden des Abspiel-Modus: ${e.localizedMessage}" + } + } + } + + /** + * Lädt die Tour-weiten Abspiel-Vorgaben für die aktuell ausgewählte Tour. + */ + private suspend fun loadTourDefaultsForCurrentTour() { + runCatching { + val defaults = repository.getTourDefaults(_selectedTour.value) + _tourDefaults.value = defaults + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Laden der Tour-Vorgaben", e) + } + } + + /** + * Erzeugt einen neuen Wegpunkt-Vorschlag mit den aktuellen Tour-Vorgaben. + * Wird vom MapScreen und WaypointListScreen für den FAB-Dialog verwendet. + */ + fun newWaypointWithDefaults( + lat: Double = 0.0, + lng: Double = 0.0, + tour: String = _selectedTour.value + ): Waypoint { + val defaults = _tourDefaults.value + return Waypoint( + latitude = lat, + longitude = lng, + tourName = tour.ifBlank { Waypoint.DEFAULT_TOUR_NAME }, + playbackMode = defaults.playbackMode, + maxPlayCount = if (defaults.playbackMode == PlaybackMode.LIMITED_COUNT) + defaults.maxPlayCount else null, + playCount = 0 + ) + } + + /** + * Speichert Begleitmusik-Einstellungen für die aktuelle Tour und + * wendet sie direkt auf den Player an. + */ + fun saveMusicSettings(settings: TourAudioSettings) { + _musicSettings.value = settings + _musicError.value = null + musicManager.saveAndApplySettings(_selectedTour.value, settings) + } + + /** Startet/Pausiert die Begleitmusik (Test-Button im Dialog / Karte). */ + fun toggleMusicPlayback() { + val settings = _musicSettings.value + if (!settings.enabled) { + _musicError.value = "Begleitmusik ist nicht aktiviert." + return + } + + // Prüfen ob Quelle konfiguriert + val sourceOk = when (settings.sourceType) { + de.waypointaudio.data.MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty() + de.waypointaudio.data.MusicSourceType.STREAM_URL -> !settings.streamUrl.isNullOrBlank() + } + if (!sourceOk) { + _musicError.value = "Keine Musikquelle konfiguriert. Bitte zuerst Begleitmusik einrichten." + return + } + + _musicError.value = null + + if (musicManager.player.isPlaying) { + musicManager.pauseMusic() + _musicPlaying.value = false + } else { + // Einstellungen immer neu laden vor Play + musicManager.player.loadSettings(settings) + musicManager.startMusic() + _musicPlaying.value = true + } + } + + /** Stoppt die Begleitmusik. */ + fun stopMusicPlayback() { + musicManager.stopMusic() + _musicPlaying.value = false + _musicError.value = null + } + + /** Springt zum nächsten Titel (nur lokale Playlists). */ + fun musicNext() { + musicManager.nextTrack() + } + + /** Springt zum vorherigen Titel (nur lokale Playlists). */ + fun musicPrevious() { + musicManager.previousTrack() + } + + /** Löscht den Musik-Fehler. */ + fun clearMusicError() { + _musicError.value = null + } + + // --------------------------------------------------------------------------- + // GPS-Track-Aufzeichnung (delegiert an TrackRecordingService via Manager) + // --------------------------------------------------------------------------- + + /** + * Fügt einen neuen Track-Punkt hinzu (Legacy-Methode, jetzt via Manager). + * Wird weiterhin unterstützt falls extern aufgerufen. + */ + fun addTrackPoint(lat: Double, lng: Double) { + TrackRecordingManager.addPoint(lat, lng) + } + + /** + * Startet die Track-Aufzeichnung via TrackRecordingService (Foreground Service). + * Der Service läuft weiter wenn die App in den Hintergrund wechselt. + */ + fun startTrackRecording() { + val ctx = getApplication() + val tour = _selectedTour.value + TrackRecordingManager.startRecording(ctx, tour) + } + + /** + * Stoppt die Aufzeichnung und persistiert den Track. + * Der Service übernimmt die Persistierung vor dem Stoppen. + */ + fun stopTrackRecording() { + val ctx = getApplication() + val track = TrackRecordingManager.currentTrack.value + // Zuerst Manager/Service stoppen + TrackRecordingManager.stopRecording(ctx) + // Auch ViewModel-seitig persistieren (als Backup) + if (track.isNotEmpty()) { + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { + repository.saveGpsTrack(tour, track) + _persistedTrack.value = track + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Speichern des GPS-Tracks", e) + } + } + } + } + + /** Löscht den gespeicherten GPS-Track der aktuellen Tour. */ + fun clearTrack() { + // Aufzeichnung stoppen falls aktiv + if (TrackRecordingManager.isRecording.value) { + val ctx = getApplication() + TrackRecordingManager.stopRecording(ctx) + } + TrackRecordingManager.clearTrack() + _persistedTrack.value = emptyList() + val tour = _selectedTour.value + viewModelScope.launch { + runCatching { + repository.clearGpsTrack(tour) + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Löschen des GPS-Tracks", e) + } + } + } + + /** Lädt den persistierten Track der aktuellen Tour. */ + private suspend fun loadPersistedTrackForCurrentTour() { + runCatching { + val tracks = repository.gpsTracks.first() + _persistedTrack.value = tracks[_selectedTour.value] ?: emptyList() + // Den persistierten Track auch in den Manager setzen (für Anzeige) + if (_persistedTrack.value.isNotEmpty() && TrackRecordingManager.currentTrack.value.isEmpty()) { + TrackRecordingManager.setTrack(_persistedTrack.value) + } + }.onFailure { e -> + android.util.Log.e("WaypointViewModel", "Fehler beim Laden des GPS-Tracks", e) + } + } + + override fun onCleared() { + super.onCleared() + manualPlayer.release() + } +} diff --git a/app/src/main/res/drawable-nodpi/matrix_contact_qr.png b/app/src/main/res/drawable-nodpi/matrix_contact_qr.png new file mode 100644 index 0000000000000000000000000000000000000000..85479e7db73ad0d9ac5d16e6ae3f69168267a9c2 GIT binary patch literal 21106 zcmbq)1yhx67cM2;(#@uk?(S{@1?iMdY3c5elum&cK{hSjNS8FyDcud{_B(&z%$z-g zFoW!Po^`Kv)r!(klgB_ML4|>V!BA9?(FEU@|9v6Dfqzp3bZ22;GzAr9B(=S=kJ`NJ z3@lT{TE_)>b^dUoz+xmK5-9Lf@nj?lTENLjATGUsEAe~A77J7SRhUFzvHQZ2tzpBk zQ6)wL50ytP0zNKx5;le+0&Eu*8V+5e#Fx)bqn~na9yULmTpa21moI-#)*f=cru>lF zTsqb??tgQ7XeHcFghHFBhI^xsCqWg&qnw`-!vDF)G!%Zf|#d$ z^?*zf@AKMmG2wf^cvU9R@e99IqUpIg2FSMS&-WFoIYL*rn+ZQ=Y!~d`%jACxYL%s> zNYG)%f9B6o>$qODTd*e>(qm3tdA@G^oXh|1-*XtwRnFMX*y2yD+5Kp{-lTl-Vov=} zoB4Mi4DVwauAb8B4R{q1r-69#o0ZQeem8#@APe>$-JvMvh9Wtf`xAn@*~#O(pFe-@ zcsg>5`Bi5#jT#2`8>(5}X_jzRkGxfuWCqPppp_}sEPveL@4!i7_uE$vxS!6GDwsfH zZ{@guLI_f$B4;qyZzqkDHkNbSRges`Bl+m;w9v$H)=~a4+3GSNsQMSdZGWo5t zrz%Y-Ym=8p z4#)LzyLu9KRb!_m3w!*WkQBM1b#Dsh4IQ8F4!VK5{;rN5FX?}C&F5(ZkDoBv)3X_T6^$Q#V{ZMRenyMnq; zRiYz_J3c{ocPA_jatxhZ&Gs|m6*GzO%86H)6wqyl-xsi{DCxCB5|J;_r0!Q-N|636 zK-Nv9RWSoeLXO8p4b%JXR%)
^s%;vKZSMOxDG`lHaqXL;ssV7mn9rA@E*Zq<0X_i2QBKyDVmF zQTcv){198IM6+usCcrx%zADx_KIC~_5H^Pna(|{>@y@Np8iCs1E;h)+_4DdYOdo_Q zV4gHn?^3KF<;v$N&_`ldfDH41b)711or0DwvcZO;dSDU| zjuQ!GDwTjUc1@3Jpv9KX{T1C&mJu(wacK0KDR-BX8JARK=D?lI&R6rt+*z&+1ZSDw zfc{6b!_<4_sI1$p)yJKNCCA}ySFf#9@CnN&@W`d^wiv{{@3zx789Sb@emJ#Vy}vBQ z!oVny^~&35W;?`&OjvMvgPLVAZYSe|-?`AryZ;T|8SGH7;8x9Z`%T-4)ANp#X1O*a zUg08m@c|d+)FC9X(;&@qgk^sn9UZoGWqMFS1Dj_ZPfHzb8_{oi;mY(^6V*W@SZZ`4 z#2LRJiIb~*FI{10H(p#XF&Q^X9on-V^_t;%{N*vh2x*b#=n7wo8i$yKLaLyvt4p538$mhipqbT`P50nUDr%l%x%Pi^2kXv6(R2_j@kfq2 z?DrU%LGYuwqNJw0{_%4#7wRAX?=0Z) zY(SQ}O}YTv;KIeqdF{{N?{LTPh9eD%`l+}^}QjpjrALx=S zxOaZIzIkJgP9Q0RtLqKUMCE(5FsaN}2n%F5HasM!@AnfYLqdh53}ZPp$cYU1-V_`C zXsJ@p>Lou`=ULGYaP+gOl>KCAq63a|Ma3{#Zm1!eO@%rNzG+ROF^Typ`mJVn{g-u@ zgJ%S^x?OK)U1+Y^Rj5*wLVGdF6tF?A0JU5j9z% zF3XDl!_oZ~xJ%g$dX?{)Q=hMlU+@FC`qxIl{ip}etMB;pRT$-4e6`!Ai=9#ZCTHsF zkdt*+@buseRK8a<3f2O5F;%JWLS#|f0^R?nW%cRg<(a+5VfpTwebs!p?{UWXX=b)k zf8a8io{1plbi&;&s;rwRWDjfnGtUBseK>xlE(@Dcs{lzHee$3#3qefZ`|r91{1hZ4 z!I^v{ac0n8Cphm?l<0o@UfexSl_+Pegwk74iX)V2(kHK1{WoJf(MX;x>&Xu>+gcieTVl!PFC&qaO>4 zu=ZnDhOr4=j|>EMNLfPPtlmss4o+hyx&AzS5TgiDOOP*m`>0`GD;gJvnOeOIW@epl zvX#M)In(CER*Qa$Z;S*n?<(GXyt0XdBmoT5$9H)h%PAmzD3U&G`gwvYT#8yoJM$dV zM%%ANmv_xI6zf!mCg~UF09nx}EeGU?J%AEc0?`R!Ukp^_UpCdQAWlS$@Yxnwir$M> z(7zG}luK}rX`DrKcTGz$!=#YCHqQk1HZxs=^6yO;ztw90f=sqdB*Ee(;Y&fiD6I?B z4v1n-1I?&!LY+)5s$VY?Sff{nMHW`m!FlK0OQunHziCCRtMLkG!sg!EsGLs6dL8pd zDqJC6CjXO#?P`<^C@9+m><`ofF8)^)#!(9N$)Li&s({Z?oO#0p>gV<6xoX5N^}ZnN z1aoB(|9OSbgmZ6W!65slbv=kSp@KiHy)|@nCf$G=?Onj_4dWdW!MS{g1E zN?JGpTl%o!r%_%Bp5XL@AS8+mVNbRZB0fGo55bqmi<6EQe>{^@H}Zk~qa)UIFi3uO zbciPpt~ISY-}aL_wQj}>lMR3_SMk2m=jCBFfg2QWfyQ3_oRN70ay3@)+p3P|dw@0H zvyeDE-CrrEv8?kJ(uXDwkQ8DF&Kk_0|LY5o^P~h{3u|kI@7xmVimPqf% zJ8orL69tr}aa5P!b7)TaKwArKYxUAivfx@+UBzmNWckQL;$R3Dju#`FGaWBi$@S{YOc_F}hLypqY^O>HiS%H-Fh#=a(-_09G-;YXpDKfC(eaA+ z6@wNv8jM>P-j4+P>dX91J7%z%UQ6<4+E^tB-a`^XYKsm`cZ*48h?a7k(V3QkfdDe#=HIf<62-H}VE`KJC?}NgM+} z5O%m|pB*~BrqI;*dIo#tM$l0>E8^VsP(E`wEb`EskqtNy7I7c z}KQ_^G zdd0}*r{ljA`BKm4QFT>iLGsD$wM}~7BtI_Lr>*>#OICEjq8s@&K5PZxVj)`q?m61_ z-I_Z(C_Pfd$donA*3a7U4_`Q7)rD183ez+_ktG=td*mIM@K6DW(`El_?CHs$`eo5R zc5vHOMHo=8;%JSS=svRv>K&TteK$zb+8T{v*VI$J*_)tCh;Fio?@jTiE!O{1vTvvG z_+ccaZ*kjw|CW~$uo)ophB5v5`H4X(^UB8$@UKiwSZZw^lDIyze^kqWbVQt5G>W}IWmh@p*B+QBF^$uxy&6UDoPpTYnmz1M;@wPFDI;08$ zqdB;IWhP)op#D!j-*0~rU)|hbVPZzJZg7rAL}L=%XqI1svuvV=l!U?IdTD_3A5F4S zwJ4X;pFe+M2-#;@YZ00g5XG;oSRLw9np^21%{IpN0nAVEf5s-G}a%{0MZ z@w5l!A@oq#Fa1|OqF+@YDgFI>?-T%{m4Q=~>1?xTN;7FJL;a@7)XV0E`}fKRT)pU> z8C%{Yg=)hJ12!6~sQlbfe7w%`%wtkRQ{QQgWL&LsZLPng?C@`$3r#xgwOn=@vR!6ar#7%j>%Elt4Ig;JN+2kf`E-gj!esfGl7xGs0k2HfkjC$0&*q@>r zh}GU}A(W5#k=dWd1T~fwXr~GTC7)kE5^7-iCo9leHwJo5SpN#RFAWe{4}Goi?R8QN zd;9I<#kjnWD>yO-b3X>e4Ch3_@5De?q}q>LWixEnW+5T_7|ubKgD-T}`#POfpC0m6 zJUnS=V6;XJpzaU)SKHzVbNxf~&$_ zZlA>6Kf%zo&@B<%t6|R4+plTC7I+joHO(>ox3S|lMR6z`V6lx@G1)PZCLe6>?L;Vc zg)vI4c%sbu_X8iX;fLw)V|YLJnlSugdKD9i2(M=e$19T>a5)Hr`<{gWM~EX!x|o<; z)p5t0;>&gQr>`oADRLZDbzRN@vY`&>*vA->S!#3K&86?AZ6fF)QqWK`887Vdtnu9> z`W{ftWb{(4vr~>GZia*vD9Zh3UXNW!lrr*6(ggRLjL<_TC*4-;pIGSVU}71jetRzq zNitw-^f+2nOs0=QCNX7iT7%OzKHW0%zuT57NK`5r+IAJ8$m4gSn2uurXa$tQna<(q zMhrW^xPSNeK@a@VdOlQT*rLGoD{zqrkkh$JeFjJ}gSy=6QLFdG{0od>%DO7DnM-zy z$C1x^7@>kAAm6JOxi^stKMjCN)2h-b><>W03m~MhS{&EXaQqEg(afWpIWG2Ef~dz( z-9w#@d;G+lc|l1?0%?RwGD;;=5n`9r0}as|e4O>Ba+6eMEFD}J@QOJf z*jJj}50J!fB|pYsKc_3vC8kFGDoHnL_BhfbJ61NnpUUL5%eR`bb^p`s4$w#eT$H;6 zwBqNd-S+4EgJ#%}!EYF(e64`wZvRMh=z>r|1}(m*Rr@QuOz0Twx|5!24*_VFDhus{K3f1 zRdc%7abdoV^VH&-NAhTb{@vT8zv1sA=H>WLHo}5(ri)?ro=R-G(kB#D+lTOIc)lQi z>gQ>`d1dtu;6Wds#NWRi+yoeZy}C_1Z?eZ_D2@vV*8Ce57UMo$e?TVOA>SPzz}3)q z!g%sPutc3;1=A8XLrW4D+#IUEpCElV;^g+ku4I`1jrCC z6w%^}PaPpU`7bMf{)~<-f4yhLswv`u8u{v>?@|unjV5!Qe<0l;j$+o zl;IaI-=8ug@@q@bF%Md9Pm2fTawYxdJ-p3z7$lC`bv@7B+>wPGLh<>K({F}DFO%O2 z6vZ+T1L7=&`N+;_)`Cu$`|Gc^EVG|>$6j^33bqt^d+U&CI^q83_}CGTS$^fKToKf) zFItLm6natPUo z-WJyXK5#D{aWhjBZgNQWcztH)88?W@gD62tG#?E|b*x?%iSP{b7S;;0f)%BVNG}TR zzcZKX>+2oYpWs7^Iqd{ym|pu)+D3dS+Vvi^aJ9CMjC8a3{q{3nR&w_(Qw?|5uzY*Ur#6t<|!ZpfSp zBae-zP@!`+0LFmE;z_|k zs3{ow62uVD7Jf-`jTVo0xn$e(WsQDZP#TU5b%fO2#qqCT&#(;B>&bXEk>N-hyvoBz zPPO3rs#ZFv8_FX33HdhH!)N)+poN;)-IkY#TEE<=&Xhl!h6p{u0dK`EY#%WcCEIl()KmYNA)a zfuzxsi-C>~X3S`gu#OB*0mrP8g^-}^tLvqCPSeD`7{c8PZ*-caw$J?Xj25*ESOK>^ zlrOgfT?;n=1iU=0zO*s6N2~jvCxhp$@Yd0p>v$aQ$o9KlfZ!$!)Gn|jK~j~ri<8yv zfmSx|ED%fju3|<5`IY{RD*vBL(4M0egmU6#Ae7Ii5&ux5VKQ0X5rS102-pZkbutznCY!%_oA4u!z@H3L)0Q`(dZ+22s;JH0jdQy5*!*EqM% z+EmcG4&AvggS5ZHRD|}Ze}Cm8Hlo{nDk+kSK(9p0Xpo_YL)KOzyn#9Cvnm!x>6HE+ zo*u2}POD+Mm1&@VocPjb7if@h?7NoMtkL_HX+EQj&!RpMmCE6@$r$5@s2q*(Y52pA z#zw*lf~pb?M!-VhWESb=VdxO}<6-H|6yGD)EAKWR_eQ;*gwJO%h(Q)%72(hg1IT-; zP`3kHT4D`tBW|~+Y{oXWr2rKMBPmw9!kvdiwv>oB^@o-Sh12;~UBS@qg;(}gvfOL* zXH-2F!$rQdzdFAs2vCP>t4J77#k+_?sJ_VI*J_w4rabo08+<)E@LTA}hi{7P)9*`fFK;evhQ#EQ^k>Gp7X&nGI~sQF0m ziycvR`@f* zb(D8bBt3dj@+Yf`ExP5XKIo14dyiMw4!{>yptuzmN@xd za{y?#2oOzUB^fa8CwIp4#<_~n$19Co`ta|3DHzqW-?0$%Xbps@muNbNy^iXQ#(%>R zh3*N@hOrQ-D;##x<-hFuGkf%-Q^%9Xc^&s^Z?3Q({KcKf&O0;ZI*pA-pnes~#<&$F zv>R@wshv!662N>Tb>V5vju$^AjwOrZ73?R1W)EFLGvfFE{tcF#60`^<+>`9$&wqx+ z;Kt$v8tC_yW8TfGH9J1Z37k6O8RM8s=0B?d9h28M3!tyHLD`rLM7^Baw@F}bQiXjp z{F1HD8>_0=xG>I0ECp1BFim3%7+8BIugsw1t#8Yy&rIvJ7ISdn7KxAfayi9>^L&l4kST^$`l zJ652E^${9}ehKR_Z5^L3RUZhgd4Ben@=VI@wJI)&7k~D`5xkx^PZ|{3FHWvAygV|< z^Wf)+w}Fa#<$Yn~#n9>b%42DId9-F*Z!w75ti_}3n1 z!{@kc9p0FCA;NDz->HMFxmmd)*X7+I;cO83J+eiDw`Z+ji<{I{K{Z?Z-qixQOzOK*kJ zfx#Rm1#3j2)MLiL9fLu?_n)T8mK@fxEr|*m7S_iM%Xi3XFdyJ7z5}$XtDm#(mp~I1 z2J9K@FqhmaI)}{>4MRDp4`kuV`>fDSLpkrD2qmGgJT8j}6S$|;(T}h&@DI7U55bZc z5MZe-P!f@db>qC9L}AD_x!$f8@@VCi`po#o2Tkz@d-M=Xjv7rYjC;FoH*-@+J2~&d zJsgy!o4czYl&eIGphp8OMF0zd^gcdwH@yJyia<*T{9W2_c7hmQPL@PMEg7JUh~-(u z;ZfkUGxfzdap=TlL3ePqgQX;>~f*3ha?E|LOTyA6rI>K|S{S1Fu6q9STr zm`M)K_|=_@I{;9alE56o%975Y&PMzx-W6u@6L?3R;bA@Ne_A|WfOo^Ih7yPi{UA-1 zA`6>o{oCDmTcK;4iWl*d^`RXlv{s|!%80ZMBh@JGf^3Y z*r=G(IzlR^hlnsmEs^c2Ntmd~k4u2H8 z>6U1v;QHfA!9q{x!y%oPNr)bBII#@@TNU`cCrk6Nvm*(S4k80-Tkf#22D{BRlCq&! z46WSr=nurIW?u*Th|P33?lMcH(Kc~2UhMhM5=D$?YXkCX9LueFQ?#65NL3EMwZBjy z+nD_pxF19&iR-^UT6$Thd=C04%2ESURp`Zt+JYJv>PZLL1=^DM%Y}FhYHTk(R~+Zf zYQPJaN&o<5byqv_LrvEno>vAxL4m6L(`n?iMEQWHdX z3NxKDq;?lEjv2)$#xEpFf_sHM<0Yv(hhd>XaZrr-Zl*%y%HHk~o-vFPsrz{Q_OA2+ zvQOyL;%;F$O?B^;Y-#m3ubz5EF#H7N16KJRtpWRms0Nbuk|_87X8dFJ*&JO1YBue3 z=0)}nweFVcSvET3lr_z#-m`=f|Br8uevm&+s=|=VebcXzkoV+wRD3aA9yJ7CMn7`a zjJc2{{wJww4%LMuc>xoq&%>R1S60fs70ZjxWbQ+?hasU#u(eT7?!r2XqflaKJ#wIz zavxt8uZ#WK(G1EM4V{A@)}`GjnFcNH-H4S~vI>0=Q~(UUYR>b15S=kT^)35VfhiCitt>GS<|$88Gmd+k}@+ ze`k7QFz!KkNGxw45+#ltWOV+efh2F!1}(&Z<+iCZ@%gUO$wMc!0*>sA;rGUFp2VNe5?X;3YzZh&r*8f$XdjJ<6orkgZawN_HCm8 zFkE3>wFekPDMq>IJcd}Z86;-=JmLYr^g7TzymWEr#hsd&d1c-B_8W4GR8{;qh<0`U z>YGjORVyiZn$Gqs(Twue5DL~kJ2RWGqM~DY6x=a$ZrB<%8^dP^_hp{eZ89$oNLsfH zPAb$vH=-BowBUh?kl~!(;p{yiMu8e}V^O5-hAaA2Y-3MEeSREcsX+R{Hl%A!J<=vW ziDR}%O1pv*Ml)(r)nYPXP0CNEl|uz*?-O<^#8NfkomN%&#K(4pd#QYn$oe>2#8M$4 zg69!xiCfcy*5=r-wY_Wuv)SZT!@@>qp<_a-D?=g4ws=`Jf07G`zWWg@uDg;nD!t(M^V!@SEGe>#}ac(6=2r}4yUnh zj!}AV;rtnbXBc7JBU$@$zwA~aAnxo783QRlL)Sq%5jwU{v4uP&7!2Vk+Etcb$?Ll6 z#JMEs5&xXCJfQDf{hir&6UtGu+cT`!V|H42JKX28l9CVOwdL1Pwi#n$L@iQLHLt((H_R6}vIc%JeOI!})({?~8-auFeMB0E zh#5M1YVVd#oUYRrT>wuMbPAGBah&M>XB)h*lUMcF1W`#vT##nv>) zqe}Y%nE&+ym*hJY1nwrAvm8<9wMV-+b!(>!8RO`$rGZyD_n~kk+6`O{al`y!1h%d@L-0-qb~$){+4~@>(oNTRam-60iH%6XKPGk4Y{z> zyCRBpsrq@+1xm@|z4;Zw8redfnfj1gcl^*U*yrC%OK59k?>WQU_L$KzD3_x)yM4MQ zn$zxOXVe8lu8-v4Pb$E?#WD#w8k0^$=EWHvslcxB}^|E;bMDQoCn2-ltB6Wl!kCuWnx%efK(1I9Bdp34S{LfxW>!qj# zBAcJo51T{$2vA>LQ@W=9Uihj)KMm$Y9@ZV+A(_{ZPg~VeUL5D%4WQk~8P%LdSoVjy zh^`QiBoFK?#?*DKmAiY`awb0aSNtI)D6dF>9d!KfK`($$5@lBIl+_E%wo`w)S4FNq ze8$-t)Wn(y)Yn0aMe!`?pEQsGP|FkZ16dGSBk7Mg;~+>1cW?z7I@R7C$Y7n++s)y= zss+sY5Q$)~Foq3RU(}Q3BuuxhbtltK>>8S|9$^KTNrL|+4TSwo>QHwWTJqr5+8Whz zJBXQh0A^pV%}jL%>={h%Dr$E5G&F{_y9-j0FGQhdgE!u>OCD86evT+h@=` z+aI^tV-(tq#DS~naX4Rapb1*b^^y}q2~}*}yz}Wz#|yv-o8q>=pXkyJf|+EXy68Eg z5D@1)^T`kFU7RXbOa=}KTlyzM&*#Ux{$LZ4@fI*~(%FqMO*#Q{49tNa^61`FfcteSs`2!w`1)n3>!MOpD91fc&_YN~B_I-+InkmU z+wK$_w{S817db4wfrQ5!nrxn)JHFnutLlobE=hE62?`|!NvL|ZK65JE^#JqDn@VZbWg4i&#>RkL5-#+544t;6HwY789W~=J$BbN>~WHevUjO z+pb^Z*7E^Uh~>Pz;&tnACpdIg04dA*h;MM>iZYXYFeSZhJt6W!@Zmwq%v!9!8emK< z@fdjEQtbH-n%Oe+osr32%?tjymKaCRCGh#?<{TnL2SP063aX^$Zgxn?EUMLNYfNWf z`R3b6!R*xOjyTrfThV z5o#OQ{v*c(Az>?i)m}b)jDk126JD5-6M7;iQfd zB+EBX!Y8d-_L-_ORp2kQ)C9vO3psa4S8wHj(zNY*;x!=r&(r5|;Vlq|DiV1-?Ey4O zyF^nG*9ecx70Bh3PkZvb-xKkD=eYiCe%@M{Gy}cYkdPhzzbT8U?tQ?RAC6m2fSZX+ z8n@pabn?Le$4v{8)F~Ww&eA$B1L1W4tDQ!1$s`zmG4$p~1p87@`Lv4vMIN0#8tg+`T1dK^|=enf1+`jD4ry8QVr#wMU@*n2rufjm&y5YdnmQA zmGb+3brO&ZCJSY=O=OmmU{GO z9*7qP5pLk~z>q;uW}kht_VM(C)mS!DkKl|q79p$N)|=*^-;_!7+=;G!B)J}!d7qlXXjv$jFQ6O8z45<7?aa2 zNm{9jpazlTzb-RfmfY~ie>>wvD8$7sBt zY31%%C%x1yeSKdIR?8b56Ob=#8JT&$j!a#^p7Hx=g1 zOnx``Um)eVb8PbIu}h;uKtsp1AA($3zVXt0FwvJ8AV|FvCQ*@*aP4+Z?e;;3o1y2c z1>z$ssYzeIg#PhmaA4eIf@Y)EvWIL z^Jfeja-W<;+yGl(%0u#RyCvEa*SiWlKx`65fIeFhcwK92CIHfaPh68>_VxgY``r1F z>s#M5m7kITBPqY5{rN8Fk@;+@#T!WqK({Cyf@A$BX?ldYQSlYYN_x}aMsY|=uVVoz zaeJ+cpVvIF5#is#{+0UOu!UejV-Y_+zE))(5d5p$pjd;iuJvDPaImK@@FBC1 zmnj8tlq#)SfnGDt#8r@v)pVu_f~y!@frrM>81Rgri$LS*QBLBlR0s$?1Ud(-!~>lo zY^H9ICQ=h3P?)+61&v-~Bf%)itkb)&JRMVC{3l%o((oJZg+Ke=o;poCm(s5v05oO> zC7N1`-&WgwM`S*sK~O`31O#{0@46Aa^}dH-(PKRJzXi!%dG1`z-}vDyKWcSTP;(h6 zNVO~>a3tg&rZJgL)#~Ut*8lZ=n?17xqQO+eJnQ1P3;lQfiDzDxmkixM_%Y^Xi>Ax= z1JqxG=S#RL5@>ut=Cha6rh}LrOZi1366CX;sxuouUYD8=sge6kW&Vco_A&z2@N0S% z7?}KWD~uB>UDXC20`C=n$n*bYZThTq!NMv06EJjtM?b2j8Fxo_esul`v=%HWY@M^O zJ@UU}#9m>HRTGEWb!Nh$_cOwV$y=DpyejP{l6kVO!2vroX-Xw~fe?Ad*_N;#DoAOb ze={F`N$z0%4B8X`hvkhe+_qGJz zj2S;H&SiX!h;L|$06+vnRn*e*Fstt*i#%h^1M)P0V|qy)kWJx%LI!#Iqy8D-7lI}V z2&~h`+nG@GjqjBqNorVb*4b+xrfuMdMKp<8EfsLItTiSPkZFb_81PUrFffArrG8ux+U=5U z295NP47XFr-OR>$WiF0H2F1ur&3hZQrb@t@4b$W{r)CL4olT^t24^3?_(WkOH%Tia2~ct;XTS4wH|*!<-%DTpIK>CDld;qeJJc7DiAGxSB2nPZIBpEoRKC1y5F za`Iw0*iNn zD7oUznGP_)!TQ|8{r!ZQ3((hIo^Lv=`cn+o>-8`$(oglv2>*-pf+ZW6eE9bpAj<|& zR&@_DgN6{7$Y#l#%oU4po28BX!^e#b*gM^Doch`tUnmF~gp?U&}YpY@ZPLy2tg z0LLMl1+hG!oKfrctXGbiU!c zvp_4by4cXb3YGrq{p{ub^eFQ6qXuIN2)488$<2t^R$|I{HQCD3PxJL+E(I|@F#@(5r0QC_4)t#yx~g$3st zf75YBD0Y4^CC&Wk&dIl%{(W=MdD;iQfb#Oz0&le>DIqTJI0m~b` z1pPG@)Cn;ocuyyQ;Hc0`OGlDq=>Ek|8K^@Gjj+A=sKW@{izi?w%+3fbN_QD)nA_d|v zqSSrOfb`E}4wfFb(tZHVU-V{Ky5#%Z0f3Up4Mn*dEqQzni(lxJ_X7Jj&;EH%AOvix zJc79`&6X31n%xsmE)2AsfPes@9Qr82?rWd6cozQ4{dx0*KXo>*5oPkR{06_!wJIxQ zQ>%uouDA}fdQ}X1QbA5J7jW^SWQ;x^@#5UA+_WQ&1X9?Cwf$Q?(E6rAt$-&i`S_W29dCrWSt~(8o_Tp-_?IxGUY!=Dq1zbqr~*3hybH@nW%Ii8t+;Wbu(#K1atq=19=wTP0WM# zi|1u99j+R)CXw~koG{Y2j9Y$d^aontC_rC(1;KcBE%i|Nt}<+XjU@XFjtIr)@AE13 z2hCf!-J0Ztws_*-lCWj%0=?G>33*U$KiD>_Gvo|!kKF9$gmoT1dr1Or0ZwGWu+ z=o0G_>~H={&6*1fk0T&-WT{cp_9SYT+;pwilYNiuScXXlVu3-Gb@#bBZwGgSx9lDk zDvL*zqQ;2k-19yk3(je7LsW<DFR8kytb>NDlgi z3qe)>X+D6-hX59&j+PpY-DlnbNZA`N0=T*YNq+G6x8#hYMP;K4f=$;JTz9`{5jLfdJy|l*uycfEdOVd| zO6BNj8FHx!p_2gFH(U-Q$=h;jSpdo1ym4T>ZD}d)0}E_v`GQ;czz#KfaDTyD3w8SB z+b{J>m+;#c-hooi{PW>Z^}&y|mkD6WN{{RG&3n~PKgrx;lCVWiqzXFsBCz20eU@SG zyP6WOu{>=XHR0B$mX@;AN?pz_2L_=0=2onHqx>#M*qo2){pE!_{vgYrh&h1?4M0Vq zVRS3k_PLtT>8rWyenm@*M;-rhKaiqxvp4nJouXp3;}<5( z0tXV9JfGbIf;kAv&Yz#3fiOWtR3^+N*({Fst>uPnk!|rde(gSPZ{KK=cms;Vwc5huLGN1`4nC&*nTduXzS9O!$))Rgt>GJ3{vWRAG8 z2&3xl|2Cy{A;^!CXNcqDwMC{`M(Bu&@pQG(K!n7*t3n&rP;Au9eUjb!P8^lH*Zw`k3cxY#~3 z;9wO;emCwg!mIs#i(tJ_ozq=sJM-VSTtzaxyKAs;J)SN2dPy8u>&|`E3%`sYQ%~^C zL6^nz6Zq~)8jLdSFftUNLj$E2!Z!a_I}>satg0d`%;2bwLsX!O0wT4=TPMec5n+ms^26|6vQbOR@AVj@v&NPkf?TGg-C2`wrs1my|& zi)DF;61<-j7F#fkTT`Ka;CkgeGCI^-ERTT}Nt`grO$g&P-FsQ0_C$f#Sp|Co^`tJk z1sa_i_^YmTRBh3t8L;>`*@5$6^-OXF9#S~aWm5CRGzJUI64d`w%2~fP9d=m>X2xPFi>0Q z~q zzMd1Ikpr;DVcOHbAkjDV#Z4+nI0`MHXSnJ2E(jZtmk><1TLdIO)9M4UKxw6cQHpiQ zidVL*__u!lCHa|{LYdJ>C%tY1!-YVvU0j9gB)>L4~mmk3w{fj9umBu(UOdCx%W0fzGg4-QxM{#%T74g9YQoJuOzyI6cLV;U|{(aS= z>4s%fqJ44u7auWRh~ZR$Q{N{CtXRg(nU9I#MJQfCc^IzKw{sl$g0jJ&z5m_QSmX}T zdZc?mGtL@$86J%DW{Rue8-QDNSUb>1-?0nY8LO8o0Ei%<;eVX>SBv($1MjrS?h|8& zK99*)HNnimkW{_k;B&y8yE3FLgs3oYwGcOxB%!LNOG#`5ji; zg+FMk$pf+KD4@@=!qFM^Z;!#KTv-QQor~n6*kTRdW(9IWLD|>CcfqG?Aa~55jAp;S zR`H>LfCIfXQeu_y805%^m|2)6jG_*(c9E|~*Vefszbb;VlquE0tqaZaGCu&{b-_WZ zrgX_2_o8dr?nY*TTH7Mca&}p|?O8biCuUZs_l&BVMYSFY{(FocorrZ#llL>=9Ny%+ zuDgr@-x7o=0T_fm`8wVl-3_pB&u>y)(SN@%#E^}VApv&-LT8ea0PO(^HJJoNHWS2H z#S!2M$Mh!6a{z*LRUT2QO9R)#W4H?~Z9m7RU85bJ*-1Ea(0zsyy9*MO#(P z!BfOQs(yk(i}rVonZ&co+v<;JHbIvUB=I~31SGJg-*yDFLrg~FrP>!cCWWCK<*itt z2EFCV=skG;lOH4PcmjsasK`jK%TXo<7-6zpXXD7ubCOO`El(`fN0zp^b_!u=ZOl0g zj^3(uWiALBI&$jrNK!3juv#MEj0OlHS~UGZ$Y2e!>PzmDuY8jDZpDOYxVHmc&GGvB za*Jz@QB)_zEc*J%I9u2(ms0DLCnx+h<Ha4WSY@zb!{1O0WIG$TQf<0MPN5k#3mtp3S4_m2w@9j{TBM^BsOHYNuzUCKp z%fJ#^_Sde%(%r;zneYB2n92I~qhNjc+~oaBs?FA^fr>^$f0Tj0s~OldTxH@6YQ~$H z%~MF3E>$~e&oGXgKC*Q|#vq2XM(_2HO4E2_iuxCu$ADT6;HsGLsMI8srT5w)JK>*F z;;aAmAd_w50i@U7S@<#IwTj@gEVLs7t1eQ&9-?d@86F?iSV_?MyQQU{Bq+uf%yY5?(Ub^Nx6Lf_l zdf8r={RxncIf!sG$fK4QoKRLi;oc%|2{*T?4+mt9E&Kv(%zGM)N(cE--1DFBzA?_4 zaw)A2gmN>ut)DT0B{JD1vI(Y`io>U_c3|~MF#Tj}wC`R}@b7O~8D@`Sem3=}kOh2U zW2BBCmR3ORB$!MP=b2pjj6V|>M7;;^k9vbtjoVdiJ|txe7-HI+I@2g%`W`{` zu{`FZh-kv0NE)^;=St$XrQw10B!9Od1?Y;#x;^gi9rbN`PY=m%+ycxgU6wvq&@p56 zTc=o47kf;rj+o!K+8h4a?(maH@KLu7{gbfMr+idho#M(D#g9|K_@kME&ds7 zy|A29jRP$dRn}Jc;e4?fT1(ZL(u4Mfj&2-UFp*AFBoEmahv!K9?<~4K+06mTcy`i8 zcg*wNUud=6{1hPAgM%AeByem1I6?bPMJ~rBs1A$)BLMd-DqIPd6$@)#z-;?H#^II$ zM70j`mU)9jFNru{ti(y4i+_i!+8%=AwEiYwE*`DpLQYRtxi5-CrbKn;*K+ij~D-gJaO)u!{M~%EUV~o;H87ls|`Bd zC!7~e6RxP*;FshpG6RXpm(Uk0|J>r1y3$<)kGAo~r2IXtoh(&fCN##uIBChX1rM^#+Dv)EQ8yTZ4bde1)rcny@ zTPJA8SJ18$9bG)4+`q!K2BYKPM>xj~X*-@fYgW-%8`=hBrn|M1nY79dHby)cL25@o z)QXdq)b08e!MRtKu~Ztdm)qrN7jNCrB4V{|6Mx->x6<4M_bF?LoE8 zC^pg)Qb_}HRa!a)^&(V|bme?GiYvc8jM! zy3I@O**x4>#GArg*ElI@aBt{Kxv-&1ByPj6e84f zvx!`VQAHO0@#7D9lp@*k*kDVrAFQQ{Gp^Rf#7D_qIOfT$VvbRW65^=z6mng4hHg5t zHmJpn;m$}E^r=<|J{&AW^By=kR6lRH{_bygVLSyA!|+38G5qZPO7lOHLL>N3*r<2c zcfY*NMVzFt7WQ%PeM|si^0Rm5$hdNXHG* zQDhQ&_2=0sAEA53Qg@=0XeoCb#21ufRpwQl(V# z68s>bi)1cegxI1Dgqf7*WlJpaw2OgfM@t9$`>P0blz~J>NGgPMw>A&`V>uaJ#IpjH z69M!IuuAmJM}2OZ%>z=VUqO)sGJj$tA4n%X4=A!g4AWga@?=edUt!0q<}1&NIF%+W zOa*Q>%KNOdJ-jz%azSdn ztn_cNc((~|dk~IXKdJcd#uGCMrGqKY!kM|T$5=As%xqp1h}(dmEC`PiOS$=IL`g!5 z^`)djwU(PG6kd~$JJAycjVX|v4Y+a4nNtkqPmODMzu)YJd#;W-b| YvtPVT_u^BqOOixK + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_my_location_puck.xml b/app/src/main/res/drawable/ic_my_location_puck.xml new file mode 100644 index 0000000..03a082b --- /dev/null +++ b/app/src/main/res/drawable/ic_my_location_puck.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..81a2d00 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,310 @@ + + + GPS2Audio + + + waypoint_service_channel + Wegpunkt-Ortungsdienst + Läuft im Hintergrund, um Wegpunkte zu erkennen + GPS2Audio aktiv + Überwache GPS-Wegpunkte… + Wegpunkt erreicht: %1$s + + + GPS-Track-Aufzeichnung + Läuft im Hintergrund während der GPS-Track aufgezeichnet wird + GPS2Audio – Track-Aufzeichnung + GPS-Track wird aufgezeichnet%1$s + Standort-Berechtigung fehlt. Bitte erteilen Sie die Berechtigung „Genauer Standort“ in den App-Einstellungen, damit die Track-Aufzeichnung im Hintergrund funktioniert. + + + Wegpunkt hinzufügen + Wegpunkt bearbeiten + Wegpunkt löschen + Noch keine Wegpunkte angelegt. + Name + Breitengrad (Latitude) + Längengrad (Longitude) + Radius (Meter) + Audiodatei + Audiodatei auswählen + Keine Audiodatei ausgewählt + Speichern + Abbrechen + Löschen + Wegpunkt löschen? + Dieser Wegpunkt wird unwiderruflich gelöscht. + Wegpunkt löschen? + „%1$s“ wird unwiderruflich gelöscht. + Dienst starten + Dienst stoppen + Berechtigungen erforderlich + Für die Wegpunkt-Erkennung werden Standort-Berechtigungen benötigt. + Für Hintergrund-Erkennung wird die Berechtigung für Standortzugriff im Hintergrund benötigt. Bitte in den Einstellungen aktivieren. + Einstellungen öffnen + Aktuellen Standort verwenden + Zurück + Meinen Standort anzeigen + + + Karte + Kartendaten © OpenStreetMap-Mitwirkende + + + Als Wegpunkt übernehmen + Koordinaten aus aktuellem GPS-Standort vorausgefüllt. + + + Marker antippen · Karte lange drücken + Bearbeiten + Löschen + Unbenannter Wegpunkt + + + Track aufzeichnen + Aufzeichnung stoppen + Aufzeichnung läuft + Track gespeichert + Track löschen + Wegpunkt am Track-Ende + %1$d Punkte · %2$s + + + Als JSON exportieren + JSON importieren + Als GPX exportieren + GPX importieren + Erfolgreich + + + Wegpunkt aus aktuellem Standort + GPS-Position wird ermittelt… + + + Über diese App + Über diese App + GPS2Audio + Überwacht GPS-Wegpunkte und spielt beim Betreten des jeweiligen Radius automatisch eine Audiodatei ab. Wegpunkte können auch manuell in der Übersicht abgespielt werden. + Version + 1.0 + Entwickler + Marcel Mayer + E-Mail + marcel.mayer@nesohub.org + Lizenz + Apache License 2.0 + 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. + https://www.apache.org/licenses/LICENSE-2.0 + Schließen + Copyright 2026 Marcel Mayer + + + Matrix-Kontakt + Scanne den QR-Code oder tippe den Button, um mich über Matrix zu kontaktieren. + @neso:nesohub.org + https://matrix.to/#/@neso:nesohub.org + Matrix-Chat öffnen + Matrix-Chat mit @neso:nesohub.org öffnen + QR-Code für Matrix-Kontakt @neso:nesohub.org + + + Abspielregeln + Abspiel-Modus + Bei jedem erneuten Betreten + Nur einmal + Begrenzt oft + Maximale Abspielanzahl + Anzahl (z. B. 3) + Muss eine Ganzzahl ≥ 1 sein + Bisher abgespielt: %1$d + Zähler zurücksetzen + Zeitplan + Zeitplan aktivieren + Startdatum und -uhrzeit + Enddatum und -uhrzeit + Start auswählen … + Ende auswählen … + Start entfernen + Ende entfernen + Tägliches Zeitfenster + Täglicher Beginn + Tägliches Ende + Beginn auswählen … + Ende auswählen … + Zeitfenster entfernen + Tipp: Ein Zeitfenster darf Mitternacht überschreiten (z. B. 22:00 – 06:00). + Ende muss nach dem Start liegen + + + Bei jedem Betreten + Nur einmal + Max. %1$d× + %1$d gespielt + Zeitplan aktiv + + + Tour + Tourname + Vorhandene Tour wählen oder neuen Namen eingeben + Tourname darf nicht leer sein + Eine Tour mit diesem Namen existiert bereits + Neue Tour + Tour umbenennen + Tour löschen + Tour löschen? + Die Tour „%1$s" wird gelöscht. Alle Wegpunkte dieser Tour werden in die Standardtour verschoben. + Standardtour kann nicht gelöscht werden + Diese Tour enthält noch keine Wegpunkte.\nTippe auf +, um einen hinzuzufügen. + Standard + + + Abspielregel aus Tour-Vorgabe übernommen. Kann hier überschrieben werden. + + + Manuelle Wiedergabe + Abspielen + Pause + Vorheriger Wegpunkt + Nächster Wegpunkt + Kein Wegpunkt mit Audio verfügbar + Manuell: %1$s + Wiedergabe / Pause + Einzel-Wiedergabe aktiv + + + Diesen Wegpunkt abspielen + Diesen Wegpunkt pausieren + Keine Audiodatei zugewiesen + Einzelwiedergabe: %1$s + Einzelwiedergabe (kein Auto-Weiter) + + + Begleitmusik + Begleitmusik + Tour: %1$s + Begleitmusik aktivieren + + + Musikquelle + Lokale Playlist + Stream-URL + + + Playlist + Noch keine Audiodateien ausgewählt. + Dateien hinzufügen + Playlist leeren + Entfernen + Zufällige Reihenfolge + + + Stream-URL + Direkte http/https-Audio-URL erforderlich (z. B. Internetradio) + Bitte eine URL eingeben + URL muss mit http:// oder https:// beginnen + 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. + + + Verhalten bei Wegpunkt-Audio + Pausieren und fortsetzen + Musik pausiert während des Wegpunkt-Tons und setzt danach fort. + Fade-out / Fade-in + Musik wird sanft ausgeblendet, Wegpunkt spielt, danach Einblenden. + Leise unterlegen (Duck) + Musik läuft leiser weiter während der Wegpunkt-Ton spielt. + Normal unterlegen + Musik läuft auf normaler Lautstärke parallel zum Wegpunkt-Ton. + + + Fade-Dauer: %1$d ms + Lautstärke während Wegpunkt: %1$d %% + + + Nach Waypoint automatisch Begleitmusik starten + Startet die Begleitmusik nach dem Wegpunkt-Ton, auch wenn sie zuvor nicht spielte. + + + Vorschau / Test + Abspielen / Pause + Stopp + + + Atmo + Wegpunkt-Tracks + Nicht aktiviert – tippe auf Konfigurieren + Keine Quelle konfiguriert + Lokale Playlist: %1$d Titel + Stream: %1$s + Konfigurieren + Autostart nach Waypoint aktiv + Pausieren & fortsetzen + Fade-out / Fade-in + Leise unterlegen + Parallel weiterspielen + + + Live + Als nächstes: + Vorheriger Titel + Nächster Titel + Begleitmusik stoppen + Begleitmusik abspielen / pausieren + %1$d / %2$d + Unbekannter Titel + Stream + Bereit – tippe ▶ zum Starten + + + Tour-Zähler + Einstellen + %1$d× abgespielt + %1$d begrenzt + %1$d einmalig + %1$d jedes Mal + Alle Wegpunkte: bei jedem Betreten + + + Live / PTT + PTT starten + PTT stoppen + Bereit + Mikrofon aktiv + Mikrofon-Berechtigung fehlt + Kein Eingabegerät gewählt (Systemstandard) + Wegpunkt-Audio läuft + Für die Live/PTT-Funktion wird die Mikrofon-Berechtigung benötigt. + Berechtigung verweigert. Bitte in den App-Einstellungen aktivieren. + PTT ist aktiv – Wiedergabe wird nach dem PTT-Ende fortgesetzt. + ⚠ Ohne Headset kann Echo auftreten. + Audiogeräte + Tippe ▶ zum Starten – Mikrofon wird direkt übertragen + Eingabe + Ausgabe + Systemstandard + + + Audiogeräte auswählen + Eingabegerät (Mikrofon) + Ausgabegerät (Lautsprecher) + Geräte aktualisieren + Speichern + Geräte-IDs können sich nach Neustart oder Trennen/Verbinden ändern. Bei nicht verfügbarem Gerät wird automatisch der Systemstandard verwendet. + Keine Eingabegeräte verfügbar + Keine Ausgabegeräte verfügbar + Bluetooth + USB + Internes Mikrofon + Lautsprecher + + + Zähler je Tour + %1$d Wegpunkt(e) in dieser Tour + Gesamt abgespielt: %1$d + Zähler zurücksetzen + Alle Zähler dieser Tour zurücksetzen + Abspiel-Modus für alle Wegpunkte setzen + Abspiel-Modus + Zähler beim Anwenden zurücksetzen + Auf alle Wegpunkte anwenden + Gilt für alle vorhandenen Wegpunkte dieser Tour. Neue Wegpunkte erben diesen Modus automatisch als Tour-Vorgabe. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c7e6d59 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +