Release GPS2Audio v2.7.38 Samsung audio fix
This commit is contained in:
@@ -37,6 +37,7 @@ import java.util.Locale
|
||||
import java.util.UUID
|
||||
import de.waypointaudio.io.BackupImportManager
|
||||
import de.waypointaudio.io.ImportExportManager
|
||||
import de.waypointaudio.ui.RoutePlan
|
||||
import de.waypointaudio.repository.WaypointRepository
|
||||
import de.waypointaudio.service.BackgroundMusicManager
|
||||
import de.waypointaudio.service.LivePttManager
|
||||
@@ -46,10 +47,13 @@ import de.waypointaudio.service.TrackRecordingManager
|
||||
import de.waypointaudio.service.WaypointLocationService
|
||||
import de.waypointaudio.service.MusicPlaybackState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -121,12 +125,42 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _tourList = MutableStateFlow<List<String>>(listOf(Waypoint.DEFAULT_TOUR_NAME))
|
||||
val tourList: StateFlow<List<String>> = _tourList.asStateFlow()
|
||||
|
||||
/**
|
||||
* v2.7.5 — Öffentlich sichtbare Tourliste für die UI (Audio-Bibliothek, Tabs).
|
||||
* Die Standard-Tour (DEFAULT_TOUR_NAME) wird ausgeblendet, sobald mindestens
|
||||
* eine echte (benutzerdefinierte) Tour existiert. Intern bleibt DEFAULT_TOUR_NAME
|
||||
* stets als Fallback erhalten — Daten werden nicht gelöscht.
|
||||
* Wenn keine echten Touren vorhanden sind, wird DEFAULT_TOUR_NAME als
|
||||
* einziger Eintrag angezeigt (leerer Zustand für neue Nutzer).
|
||||
*/
|
||||
val visibleTourList: StateFlow<List<String>> = _tourList
|
||||
.map { list ->
|
||||
val realTours = list.filter { it != Waypoint.DEFAULT_TOUR_NAME }
|
||||
if (realTours.isEmpty()) listOf(Waypoint.DEFAULT_TOUR_NAME) else realTours
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = listOf(Waypoint.DEFAULT_TOUR_NAME)
|
||||
)
|
||||
|
||||
/**
|
||||
* 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<String>()
|
||||
|
||||
/**
|
||||
* v2.7.14 — Neutral-Explorer-Modus-Flag.
|
||||
* Wenn true, unterdrückt der combine-Kollektor den automatischen Upgrade
|
||||
* von DEFAULT_TOUR_NAME zur ersten echten Tour. Das ermöglicht einen
|
||||
* bewusst leeren Explorer-Einstieg aus der Übersicht/Audio-Bibliothek.
|
||||
* Wird durch selectNeutralTourForNewExplorer() aktiviert und durch
|
||||
* selectTour(echte Tour) oder upsertFromNeutralExplorer() wieder deaktiviert.
|
||||
*/
|
||||
@Volatile
|
||||
private var _neutralExplorerMode: Boolean = false
|
||||
|
||||
/**
|
||||
* Aktuell ausgewählte Tour (für Tabs und Filter).
|
||||
*/
|
||||
@@ -163,6 +197,19 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _manualPlaying = MutableStateFlow(false)
|
||||
val manualPlaying: StateFlow<Boolean> = _manualPlaying.asStateFlow()
|
||||
|
||||
// v2.7.37 — Kurze Stille zwischen Clips/Wegpunkten bei der manuellen
|
||||
// Sequenz-Wiedergabe ("Hörbuch-Gefühl"). Nur im Auto-Advance-Pfad der
|
||||
// Tour-/Sequenz-Wiedergabe; NICHT bei GPS, PTT, Atmo oder Einzelclip-Vorschau.
|
||||
// Bewusst kein UI-Setting und keine Persistenz — fester interner Wert.
|
||||
private var manualGapJob: Job? = null
|
||||
|
||||
/** Bricht eine laufende Auto-Advance-Pause ab, sodass kein verzögerter
|
||||
* Start nach Stop/Pause/Skip/Tour-Wechsel mehr feuert. */
|
||||
private fun cancelManualGap() {
|
||||
manualGapJob?.cancel()
|
||||
manualGapJob = null
|
||||
}
|
||||
|
||||
/** Name of the waypoint currently loaded in the manual player (null = nothing loaded). */
|
||||
private val _manualCurrentName = MutableStateFlow<String?>(null)
|
||||
val manualCurrentName: StateFlow<String?> = _manualCurrentName.asStateFlow()
|
||||
@@ -265,6 +312,66 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
get() = _activeTourRoute
|
||||
private val _activeTourRoute = MutableStateFlow<de.waypointaudio.data.TourRoute?>(null)
|
||||
|
||||
// ─── Session-Route (v2.6.2) ───────────────────────────────────────────────
|
||||
// Hält die zuletzt im Explorer berechnete/geplante Route (OSRM oder Planlinie)
|
||||
// für die aktuelle Tour. Überlebt Navigation/Workspace-Wechsel, da im ViewModel
|
||||
// gespeichert. Wird beim Tourwechsel oder explizitem Löschen geleert.
|
||||
// Kein Datenbankschreiben — dient ausschließlich der Session-Wiederherstellung.
|
||||
// Enthält: tourName (zur Validierung) + TourRoute-Objekt.
|
||||
private val _currentSessionRoute =
|
||||
MutableStateFlow<Pair<String, de.waypointaudio.data.TourRoute>?>(null)
|
||||
val currentSessionRoute: StateFlow<Pair<String, de.waypointaudio.data.TourRoute>?> =
|
||||
_currentSessionRoute.asStateFlow()
|
||||
|
||||
/**
|
||||
* v2.6.2 — Speichert die aktuell berechnete/geplante Route für die laufende
|
||||
* Explorer-Session. Wird nicht in die Datenbank geschrieben, sondern dient
|
||||
* der Wiederherstellung der Kartenvisualisierung nach einem Workspace-Wechsel.
|
||||
* Zusätzlich wird die Route dauerhaft als Tourroute persistiert, damit sie
|
||||
* auch nach App-Neustart sichtbar ist.
|
||||
*/
|
||||
fun setCurrentSessionRoute(
|
||||
tourName: String,
|
||||
route: de.waypointaudio.data.TourRoute
|
||||
) {
|
||||
_currentSessionRoute.value = tourName to route
|
||||
// Gleichzeitig dauerhaft persistieren, damit activeTourRoute beim Zurückkehren
|
||||
// die Route enthält und savedRouteLine sie als Backup zeichnet.
|
||||
saveTourRouteFor(tourName, route)
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.6.2 — Löscht die Session-Route (z. B. beim Tourwechsel oder explizitem
|
||||
* Löschen durch den Nutzer). Berührt nicht die persistente Tourroute.
|
||||
*/
|
||||
fun clearCurrentSessionRoute() {
|
||||
_currentSessionRoute.value = null
|
||||
}
|
||||
|
||||
// ─── Session-Plan (v2.7.2) ───────────────────────────────────────────────
|
||||
// Hält den zuletzt genutzten RoutePlan (Start/Via/Ziel) pro Tour.
|
||||
// Überlebt Navigation/Workspace-Wechsel, da im ViewModel gespeichert.
|
||||
// Wird beim echten Tourwechsel geleert. Kein DB-Schreiben.
|
||||
private val _currentSessionPlan =
|
||||
MutableStateFlow<Pair<String, RoutePlan>?>(null)
|
||||
val currentSessionPlan: StateFlow<Pair<String, RoutePlan>?> =
|
||||
_currentSessionPlan.asStateFlow()
|
||||
|
||||
/**
|
||||
* v2.7.2 — Speichert den aktuellen RoutePlan für die laufende Session.
|
||||
* Wird bei jedem Plan-Update aufgerufen (Start/Via/Ziel-Änderung).
|
||||
*/
|
||||
fun setCurrentSessionPlan(tourName: String, plan: RoutePlan) {
|
||||
_currentSessionPlan.value = tourName to plan
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.7.2 — Löscht den Session-Plan (beim Tourwechsel oder explizitem Leeren).
|
||||
*/
|
||||
fun clearCurrentSessionPlan() {
|
||||
_currentSessionPlan.value = null
|
||||
}
|
||||
|
||||
// ─── Tour-Cover (v2.5.5) ──────────────────────────────────────────────────
|
||||
// Persistente Map tourName → TourCover. Wenn keine Auswahl existiert,
|
||||
// behält die UI ihre deterministische Standard-Optik (siehe TourHeroSection).
|
||||
@@ -301,6 +408,16 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
if (current.isNotBlank() && current !in combined) {
|
||||
_selectedTour.value = combined.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
|
||||
}
|
||||
// v2.7.5 — Wenn Standard-Tour ausgewählt ist, aber echte Touren existieren,
|
||||
// automatisch zur ersten echten Tour wechseln. Standard bleibt interner Fallback.
|
||||
// v2.7.14 — Wenn _neutralExplorerMode aktiv ist, wird dieser Upgrade NICHT
|
||||
// durchgeführt: Explorer soll leer/neutral bleiben (Einstieg aus Übersicht).
|
||||
if (_selectedTour.value == Waypoint.DEFAULT_TOUR_NAME && !_neutralExplorerMode) {
|
||||
val firstReal = combined.firstOrNull { it != Waypoint.DEFAULT_TOUR_NAME }
|
||||
if (firstReal != null) {
|
||||
_selectedTour.value = firstReal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Begleitmusik-Einstellungen für die initiale Tour laden
|
||||
@@ -329,10 +446,15 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
_activeTourRoute.value = map[_selectedTour.value]
|
||||
}
|
||||
}
|
||||
// Beim Tourwechsel die aktive Tourroute aktualisieren.
|
||||
// Beim Tourwechsel die aktive Tourroute und Session-Route aktualisieren.
|
||||
viewModelScope.launch {
|
||||
_selectedTour.collect { tour ->
|
||||
_activeTourRoute.value = _tourRoutes.value[tour]
|
||||
// v2.6.2 — Session-Route leeren, wenn der Nutzer die Tour wechselt.
|
||||
// So zeigt der Explorer nach einem Tourwechsel keine fremde Route.
|
||||
_currentSessionRoute.value = null
|
||||
// v2.7.2 — Session-Plan leeren beim Tourwechsel.
|
||||
_currentSessionPlan.value = null
|
||||
}
|
||||
}
|
||||
// v2.5.5 — Tour-Cover beobachten.
|
||||
@@ -415,13 +537,59 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
/**
|
||||
* v2.5.34 — Explorer-Kontext-Fix:
|
||||
* Setzt die ausgewählte Tour auf den neutralen Standard zurück.
|
||||
* Wird aufgerufen, wenn der Explorer aus der Audio-Bibliothek/Home
|
||||
* geöffnet wird, damit kein zuletzt aktiver Tour-Kontext übernommen wird.
|
||||
* Setzt die ausgewählte Tour auf den neutralen Standard zurück,
|
||||
* AUSSER wenn bereits echte (nicht-Standard) Touren existieren –
|
||||
* dann wird direkt die erste echte Tour gewählt.
|
||||
*
|
||||
* v2.7.10 — Waypoint-Persistence-Fix:
|
||||
* Wenn eine spontane "Unbenannte Tour" im Explorer erzeugt wurde und der
|
||||
* Nutzer den Explorer verlässt und erneut öffnet, darf selectDefaultTour()
|
||||
* nicht blind auf DEFAULT zurückspringen und die frisch erstellten Wegpunkte
|
||||
* hinter einem Tour-Filter verstecken. Stattdessen: Wenn echte Touren
|
||||
* vorhanden sind, sofort zur ersten echten Tour wechseln (gleiche Logik wie
|
||||
* im combine-Kollektor, aber synchron, um Race Conditions zu vermeiden).
|
||||
*
|
||||
* Der GPS-Dienst und die Wiedergabe werden dabei NICHT unterbrochen.
|
||||
*/
|
||||
fun selectDefaultTour() {
|
||||
selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
|
||||
val knownTours = _tourList.value
|
||||
val firstReal = knownTours.firstOrNull { it != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME }
|
||||
if (firstReal != null) {
|
||||
// Echte Touren existieren – direkt zur ersten echten Tour wechseln.
|
||||
// Kein Rücksprung auf DEFAULT_TOUR_NAME, damit Wegpunkte sichtbar bleiben.
|
||||
selectTour(firstReal)
|
||||
} else {
|
||||
// Keine echten Touren – Standard-Tour ist der einzig sinnvolle Zustand.
|
||||
selectTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.7.14 — Neutraler Explorer-Start aus der Übersicht/Audio-Bibliothek.
|
||||
*
|
||||
* Setzt selectedTour explizit auf DEFAULT_TOUR_NAME ("Unbenannte Tour") und
|
||||
* aktiviert den Neutral-Explorer-Modus-Flag, der den automatischen Upgrade
|
||||
* durch den combine-Kollektor (v2.7.5-Logik) verhindert.
|
||||
*
|
||||
* Konzept:
|
||||
* - Explorer aus Audio-Bibliothek/Übersicht → diese Funktion → leerer Kontext
|
||||
* - Explorer aus Touransicht/WaypointListScreen → selectTour(tourName) bleibt unverändert
|
||||
*
|
||||
* Der Flag wird zurückgesetzt, sobald:
|
||||
* a) der Nutzer einen Wegpunkt im neutralen Kontext setzt (upsertFromNeutralExplorer)
|
||||
* b) der Nutzer explizit eine echte Tour über selectTour() auswählt
|
||||
*/
|
||||
fun selectNeutralTourForNewExplorer() {
|
||||
_neutralExplorerMode = true
|
||||
// Direkt auf DEFAULT setzen; selectTour() würde den Guard auslösen (DEFAULT nicht in
|
||||
// knownTours wenn nur echte Touren existieren), daher direktes Assignment.
|
||||
_selectedTour.value = de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME
|
||||
musicManager.loadTour(de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME)
|
||||
viewModelScope.launch {
|
||||
loadMusicSettingsForCurrentTour()
|
||||
loadTourDefaultsForCurrentTour()
|
||||
loadPersistedTrackForCurrentTour()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTour(tourName: String) {
|
||||
@@ -429,6 +597,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
val knownTours = _tourList.value
|
||||
val safeTarget = if (tourName in knownTours || knownTours.isEmpty()) tourName
|
||||
else knownTours.first()
|
||||
// v2.7.14 — Wechsel zu einer echten Tour deaktiviert den Neutral-Explorer-Modus.
|
||||
if (safeTarget != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
|
||||
_neutralExplorerMode = false
|
||||
}
|
||||
// v2.7.38 — Audio-Zuverlässigkeit: Ein evtl. anstehender Auto-Advance-
|
||||
// Gap-Job gehört zur alten Tour. Beim Tour-Wechsel abbrechen, damit kein
|
||||
// verspäteter Start in der falschen Tour feuert. Aktiv laufende
|
||||
// Wiedergabe bleibt davon unberührt (Persistenz, s. u.).
|
||||
if (safeTarget != _selectedTour.value) {
|
||||
cancelManualGap()
|
||||
}
|
||||
_selectedTour.value = safeTarget
|
||||
// v2.5.30 — Playback-Persistenz beim Tour-Wechsel:
|
||||
// Laufende Wiedergabe (manuell oder GPS-gesteuert) wird beim blossen
|
||||
@@ -467,7 +646,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
// Use direct assignment instead of selectTour() to avoid triggering
|
||||
// music-loading coroutine before the tour list is fully stable.
|
||||
_selectedTour.value = trimmed
|
||||
// v2.7.14 — Neue echte Tour erstellt: Neutral-Explorer-Modus deaktivieren.
|
||||
if (trimmed != de.waypointaudio.data.Waypoint.DEFAULT_TOUR_NAME) {
|
||||
_neutralExplorerMode = false
|
||||
}
|
||||
// Reset manual player state
|
||||
cancelManualGap()
|
||||
manualPlayer.stop()
|
||||
_manualPlaying.value = false
|
||||
_manualCurrentName.value = null
|
||||
@@ -493,20 +677,78 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.7.7 — Spontane-Tour-Autocreate (v2.7.8: "Unbenannte Tour"):
|
||||
* Erzeugt eine neue echte Tour mit eindeutigem Namen ("Unbenannte Tour",
|
||||
* "Unbenannte Tour 2", usw.) und wählt sie sofort als selectedTour.
|
||||
* Wird aufgerufen, wenn im neutralen Explorer-Kontext (selectedTour ==
|
||||
* DEFAULT_TOUR_NAME) der erste Wegpunkt gesetzt wird.
|
||||
* Rückgabe: der Name der neu erzeugten Tour.
|
||||
* Interner Funktionsname bleibt createSpontaneousTour() für minimales Risiko.
|
||||
*/
|
||||
fun createSpontaneousTour(): String {
|
||||
val base = "Unbenannte Tour"
|
||||
val existing = _tourList.value
|
||||
var candidate = base
|
||||
var counter = 2
|
||||
while (candidate in existing) {
|
||||
candidate = "$base $counter"
|
||||
counter++
|
||||
}
|
||||
addTour(candidate) // setzt auch _selectedTour auf candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.7.7 — Upsert-Wrapper für den neutralen Explorer-Kontext.
|
||||
* Wenn [waypoint].tourName == DEFAULT_TOUR_NAME (neutral), wird automatisch
|
||||
* eine neue Spontane Tour erzeugt, [waypoint] auf diese Tour umgeschrieben
|
||||
* und erst dann gespeichert. Danach ist selectedTour = neue Tour.
|
||||
* In allen anderen Kontexten verhält sich diese Funktion identisch zu [upsert].
|
||||
*/
|
||||
fun upsertFromNeutralExplorer(waypoint: Waypoint) {
|
||||
val isNeutral = waypoint.tourName.ifBlank { Waypoint.DEFAULT_TOUR_NAME } == Waypoint.DEFAULT_TOUR_NAME
|
||||
if (isNeutral) {
|
||||
val newTourName = createSpontaneousTour()
|
||||
val adjusted = waypoint.copy(tourName = newTourName)
|
||||
upsert(adjusted)
|
||||
} else {
|
||||
upsert(waypoint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschiebt eine Tour in der Tab-Reihenfolge.
|
||||
* Index-Verschiebung: from → to. Ungültige Indizes werden ignoriert.
|
||||
* Persistiert die neue Reihenfolge sofort.
|
||||
*/
|
||||
fun moveTour(fromIndex: Int, toIndex: Int) {
|
||||
val current = _tourList.value.toMutableList()
|
||||
if (fromIndex !in current.indices || toIndex !in current.indices) return
|
||||
// v2.7.32 — Bugfix: Die Reorder-UI (TourReorderDialog) arbeitet auf
|
||||
// visibleTourList (Standard-Tour ausgeblendet), moveTour operierte aber
|
||||
// direkt auf _tourList (inkl. DEFAULT_TOUR_NAME). Lag die Standard-Tour
|
||||
// an Index 0, ergab sich ein Off-by-one, wodurch die erste und letzte
|
||||
// SICHTBARE Tour faktisch nicht mehr verschoben werden konnten.
|
||||
// Fix: Indizes beziehen sich auf die sichtbare Liste; die ausgeblendete
|
||||
// Standard-Tour behält ihre relative Position in _tourList.
|
||||
val full = _tourList.value
|
||||
val visible = full.filter { it != Waypoint.DEFAULT_TOUR_NAME }
|
||||
if (fromIndex !in visible.indices || toIndex !in visible.indices) return
|
||||
if (fromIndex == toIndex) return
|
||||
val item = current.removeAt(fromIndex)
|
||||
current.add(toIndex, item)
|
||||
_tourList.value = current
|
||||
|
||||
val reorderedVisible = visible.toMutableList()
|
||||
val item = reorderedVisible.removeAt(fromIndex)
|
||||
reorderedVisible.add(toIndex, item)
|
||||
|
||||
// Merge zurück nach _tourList: ausgeblendete Standard-Tour(en) an ihrer
|
||||
// ursprünglichen relativen Position erhalten, sichtbare Touren in neuer
|
||||
// Reihenfolge einsetzen.
|
||||
val iter = reorderedVisible.iterator()
|
||||
val merged = full.map { name ->
|
||||
if (name == Waypoint.DEFAULT_TOUR_NAME) name else iter.next()
|
||||
}
|
||||
_tourList.value = merged
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.saveTours(current) }
|
||||
runCatching { repository.saveTours(merged) }
|
||||
.onFailure { e ->
|
||||
android.util.Log.e("WaypointViewModel", "Fehler beim Speichern der Tour-Reihenfolge", e)
|
||||
}
|
||||
@@ -558,10 +800,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
viewModelScope.launch {
|
||||
val updated = current.map { if (it == oldName) trimmed else it }
|
||||
repository.saveTours(updated)
|
||||
val updatedWps = _waypoints.value.map {
|
||||
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen.
|
||||
val freshWps = runCatching { repository.waypoints.first() }
|
||||
.getOrDefault(_waypoints.value)
|
||||
val updatedWps = freshWps.map {
|
||||
if (it.tourName == oldName) it.copy(tourName = trimmed) else it
|
||||
}
|
||||
repository.saveAll(updatedWps)
|
||||
repository.saveAllForced(updatedWps)
|
||||
// v2.4.6 — Tourroute-Eintrag mitziehen, damit die Route nicht
|
||||
// verwaist unter dem alten Namen liegen bleibt.
|
||||
runCatching { repository.renameTourRouteKey(oldName, trimmed) }
|
||||
@@ -596,7 +841,14 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/** Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive). */
|
||||
/**
|
||||
* Speichert/ersetzt die Route für eine konkrete Tour (nicht zwingend die aktive).
|
||||
*
|
||||
* v2.7.21 — Waypoint-Preservation-Invariante:
|
||||
* Diese Funktion schreibt AUSSCHLIESSLICH in KEY_TOUR_ROUTES.
|
||||
* Audio-Wegpunkte (KEY_WAYPOINTS) werden durch diesen Aufruf NIEMALS verändert.
|
||||
* Der Routenplaner darf diese Funktion frei nutzen.
|
||||
*/
|
||||
fun saveTourRouteFor(tourName: String, route: de.waypointaudio.data.TourRoute) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@@ -726,6 +978,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
val nextTour = current.firstOrNull() ?: Waypoint.DEFAULT_TOUR_NAME
|
||||
if (_selectedTour.value == tourName) {
|
||||
// Reset manual player state
|
||||
// v2.7.38 — Auch den anstehenden Auto-Advance-Gap-Job abbrechen,
|
||||
// sonst startet er 1,7 s nach dem Stop fälschlich erneut Audio
|
||||
// gegen die gerade gelöschte Tour.
|
||||
cancelManualGap()
|
||||
manualPlayer.stop()
|
||||
_manualPlaying.value = false
|
||||
_manualCurrentName.value = null
|
||||
@@ -739,10 +995,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
repository.saveTours(current)
|
||||
val updatedWps = _waypoints.value.map {
|
||||
// v2.7.21 — Race-Condition-Fix: Direkt aus DataStore lesen statt
|
||||
// _waypoints.value, um sicherzustellen, dass keine Wegpunkte verloren
|
||||
// gehen, falls _waypoints.value noch nicht vollständig geladen ist.
|
||||
val freshWps = runCatching { repository.waypoints.first() }
|
||||
.getOrDefault(_waypoints.value)
|
||||
val updatedWps = freshWps.map {
|
||||
if (it.tourName == tourName) it.copy(tourName = Waypoint.DEFAULT_TOUR_NAME) else it
|
||||
}
|
||||
repository.saveAll(updatedWps)
|
||||
// Forciert schreiben, da dies eine bewusste Mutations-Operation ist
|
||||
// (Tour-Löschung mit Reassignment auf DEFAULT_TOUR_NAME).
|
||||
repository.saveAllForced(updatedWps)
|
||||
// Clear GPS track for the deleted tour
|
||||
repository.clearGpsTrack(tourName)
|
||||
// v2.4.6 — Gespeicherte Tourroute beim Löschen entfernen
|
||||
@@ -885,7 +1148,13 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
else base.withClips(newClips)
|
||||
}
|
||||
if (newWps.isNotEmpty()) {
|
||||
repository.saveAll(_waypoints.value + newWps)
|
||||
// v2.7.21 — Race-Condition-Fix: Statt _waypoints.value (könnte noch
|
||||
// leer sein, wenn der DataStore-Lade-Coroutine noch nicht feuerte)
|
||||
// direkt aus dem DataStore lesen. Das garantiert die vollständige
|
||||
// Wegpunktliste und verhindert versehentlichen Datenverlust.
|
||||
val currentWps = runCatching { repository.waypoints.first() }
|
||||
.getOrDefault(_waypoints.value)
|
||||
repository.saveAll(currentWps + newWps)
|
||||
}
|
||||
// Tour-Cover, Tour-Route und Tour-Defaults referenziell mitkopieren.
|
||||
runCatching {
|
||||
@@ -1193,49 +1462,68 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val ctx = getApplication<Application>()
|
||||
val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap())
|
||||
val tourDefaultsMap = runCatching {
|
||||
repository.tourDefaults.first()
|
||||
}.getOrDefault(emptyMap())
|
||||
val musicSettingsMap = runCatching {
|
||||
musicStore.settingsFlow.first()
|
||||
}.getOrDefault(emptyMap())
|
||||
// v2.7.36 FIX: Das gesamte Backup (Flow-Reads, ZIP-Aufbau und das
|
||||
// chunk-weise Kopieren aller Mediendateien) lief bisher direkt im
|
||||
// viewModelScope auf dem Main-Dispatcher. Bei Backups mit Medien
|
||||
// blockierte das den UI-Thread für Sekunden → ANR ("App reagiert
|
||||
// nicht / Absturz"), obwohl die Datei erfolgreich geschrieben wurde.
|
||||
// Wie der Import-Pfad läuft die schwere IO jetzt auf Dispatchers.IO;
|
||||
// die State-Updates für Erfolg/Fehler bleiben danach auf Main.
|
||||
val result = runCatching {
|
||||
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val gpsTracks = runCatching { repository.gpsTracks.first() }.getOrDefault(emptyMap())
|
||||
val tourDefaultsMap = runCatching {
|
||||
repository.tourDefaults.first()
|
||||
}.getOrDefault(emptyMap())
|
||||
val musicSettingsMap = runCatching {
|
||||
musicStore.settingsFlow.first()
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
val pkgInfo = runCatching {
|
||||
ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
||||
}.getOrNull()
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkgInfo?.let {
|
||||
runCatching { it.longVersionCode.toInt() }
|
||||
.getOrDefault(it.versionCode)
|
||||
} ?: 0
|
||||
val pkgInfo = runCatching {
|
||||
ctx.packageManager.getPackageInfo(ctx.packageName, 0)
|
||||
}.getOrNull()
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkgInfo?.let {
|
||||
runCatching { it.longVersionCode.toInt() }
|
||||
.getOrDefault(it.versionCode)
|
||||
} ?: 0
|
||||
|
||||
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
|
||||
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
|
||||
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
|
||||
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
|
||||
val tourRoutesMap = runCatching { repository.tourRoutes.first() }.getOrDefault(emptyMap())
|
||||
val tourCoversMap = runCatching { repository.tourCovers.first() }.getOrDefault(emptyMap())
|
||||
// v2.5.48 — Echte TrackDraft-Objekte für Backup lesen (nicht gpsTracks).
|
||||
val trackDraftsSnapshot = runCatching { repository.trackDrafts.first() }.getOrDefault(emptyList())
|
||||
|
||||
val input = de.waypointaudio.io.BackupExportManager.BackupInput(
|
||||
waypoints = _waypoints.value,
|
||||
tours = _tourList.value,
|
||||
tourDefaults = tourDefaultsMap,
|
||||
gpsTracks = gpsTracks,
|
||||
musicSettings = musicSettingsMap,
|
||||
tourRoutes = tourRoutesMap,
|
||||
tourCovers = tourCoversMap,
|
||||
trackDrafts = trackDraftsSnapshot,
|
||||
appVersionName = pkgInfo?.versionName ?: "",
|
||||
appVersionCode = versionCode
|
||||
)
|
||||
val selection = de.waypointaudio.io.BackupExportManager.BackupSelection(
|
||||
toursAndWaypoints = toursAndWaypoints,
|
||||
trackDrafts = trackDrafts,
|
||||
mediaAndAtmo = mediaAndAtmo,
|
||||
settings = settings
|
||||
)
|
||||
de.waypointaudio.io.BackupExportManager.writeBackup(
|
||||
ctx, uri, selection, input
|
||||
)
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
// Falls trotz interner Absicherung etwas Unerwartetes durchschlägt
|
||||
// (z. B. SecurityException auf der Ziel-URI): nicht abstürzen,
|
||||
// sondern als nicht-fataler Fehler melden.
|
||||
de.waypointaudio.io.BackupExportManager.BackupResult(
|
||||
errorMessage = "Backup-Fehler: ${e.localizedMessage ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
|
||||
val input = de.waypointaudio.io.BackupExportManager.BackupInput(
|
||||
waypoints = _waypoints.value,
|
||||
tours = _tourList.value,
|
||||
tourDefaults = tourDefaultsMap,
|
||||
gpsTracks = gpsTracks,
|
||||
musicSettings = musicSettingsMap,
|
||||
tourRoutes = tourRoutesMap,
|
||||
tourCovers = tourCoversMap,
|
||||
trackDrafts = trackDraftsSnapshot,
|
||||
appVersionName = pkgInfo?.versionName ?: "",
|
||||
appVersionCode = versionCode
|
||||
)
|
||||
val selection = de.waypointaudio.io.BackupExportManager.BackupSelection(
|
||||
toursAndWaypoints = toursAndWaypoints,
|
||||
trackDrafts = trackDrafts,
|
||||
mediaAndAtmo = mediaAndAtmo,
|
||||
settings = settings
|
||||
)
|
||||
val result = de.waypointaudio.io.BackupExportManager.writeBackup(
|
||||
ctx, uri, selection, input
|
||||
)
|
||||
if (result.errorMessage != null) {
|
||||
_errorMessage.value = result.errorMessage
|
||||
} else {
|
||||
@@ -1352,7 +1640,9 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
// ---- Persistieren ----
|
||||
runCatching {
|
||||
if (toursAndWaypoints) {
|
||||
repository.saveAll(payload.mergedWaypoints)
|
||||
// v2.7.21 — Backup-Restore ist ein forcierter Vollersatz; Preservation-Guard
|
||||
// nicht anwenden (mergedWaypoints kann leer sein bei leeren Backups).
|
||||
repository.saveAllForced(payload.mergedWaypoints)
|
||||
if (payload.mergedTours != existingTours) {
|
||||
repository.saveTours(payload.mergedTours)
|
||||
}
|
||||
@@ -1567,6 +1857,17 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
_singlePlayingActive.value = false
|
||||
}
|
||||
|
||||
// v2.7.37 — Während der Stille-Pause läuft kein MediaPlayer, der State
|
||||
// ist aber "playing". Pause-Tippen soll dann die Auto-Advance-Pause
|
||||
// abbrechen und nicht neu starten.
|
||||
if (manualGapJob != null) {
|
||||
cancelManualGap()
|
||||
_manualPlaying.value = false
|
||||
stopProgressTicker(keepSnapshot = true)
|
||||
musicManager.afterWaypointAudio()
|
||||
return
|
||||
}
|
||||
|
||||
if (manualPlayer.isPlaying) {
|
||||
manualPlayer.pause()
|
||||
_manualPlaying.value = false
|
||||
@@ -1655,6 +1956,10 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
*/
|
||||
private fun startPlayableAt(sequence: List<PlayableItem>, seqIndex: Int) {
|
||||
if (sequence.isEmpty()) return
|
||||
// v2.7.37 — Jede neue Playback-Entscheidung (Skip/Play/Resume) bricht
|
||||
// eine evtl. laufende Auto-Advance-Stille ab, damit kein verspäteter
|
||||
// Start den frisch gewählten Clip überschreibt.
|
||||
cancelManualGap()
|
||||
val safeIdx = seqIndex.coerceIn(0, sequence.size - 1)
|
||||
val item = sequence[safeIdx]
|
||||
_manualIndex.value = item.waypointIndex - 1
|
||||
@@ -1684,7 +1989,18 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
title = title,
|
||||
tour = _selectedTour.value
|
||||
)
|
||||
manualPlayer.play(ctx, item.uri) {
|
||||
manualPlayer.play(ctx, item.uri, onError = {
|
||||
// v2.7.38 — Wiedergabe konnte nicht starten (z. B. Samsung-SAF-/
|
||||
// Codec-Fehler). Zustand sauber zurücksetzen statt "spielt" ohne
|
||||
// Ton anzuzeigen, und den Nutzer informieren.
|
||||
cancelManualGap()
|
||||
_manualPlaying.value = false
|
||||
_manualCurrentName.value = null
|
||||
_currentPlayable.value = null
|
||||
stopProgressTicker(keepSnapshot = false)
|
||||
musicManager.afterWaypointAudio()
|
||||
_errorMessage.value = "Audio konnte nicht abgespielt werden. Bitte erneut versuchen."
|
||||
}) {
|
||||
if (!autoAdvance) {
|
||||
_manualPlaying.value = false
|
||||
_manualCurrentName.value = null
|
||||
@@ -1712,9 +2028,57 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
return@play
|
||||
}
|
||||
val nextItem = nextSeq[nextIdx]
|
||||
_currentPlayable.value = nextItem
|
||||
_manualIndex.value = nextItem.waypointIndex - 1
|
||||
playFromCurrentPlayable(autoAdvance = true)
|
||||
// v2.7.37 — Kurze Stille vor dem nächsten Eintrag (Hörbuch-Gefühl).
|
||||
// Läuft als abbrechbarer Job: Pause/Stop/Skip/Tour-Wechsel rufen
|
||||
// cancelManualGap() und verhindern so einen verspäteten Auto-Start.
|
||||
// Die Sequenz wird NACH der Pause erneut ausgewertet, da sich der
|
||||
// Zustand (PTT, gelöschte Wegpunkte, deaktivierte Clips) während der
|
||||
// Wartezeit ändern kann.
|
||||
//
|
||||
// v2.7.38 — Audio-Zuverlässigkeit (Samsung-Regressionsfix):
|
||||
// Die Gap ist STRIKT auf den Auto-Advance NACH einem natürlich
|
||||
// beendeten Clip beschränkt. Sie darf den Folge-Clip nur dann
|
||||
// starten, wenn währenddessen weder die Tour gewechselt wurde noch
|
||||
// der Nutzer pausiert/gestoppt hat. Andernfalls wird der verzögerte
|
||||
// Start verworfen, statt den Zustand fälschlich auf "spielt" zu
|
||||
// lassen oder Audio in der falschen Tour zu starten.
|
||||
val gapTour = _selectedTour.value
|
||||
cancelManualGap()
|
||||
manualGapJob = viewModelScope.launch {
|
||||
delay(MANUAL_GAP_MS)
|
||||
manualGapJob = null
|
||||
// Tour gewechselt während der Stille → kein Auto-Advance.
|
||||
// Laufende/zukünftige Wiedergabe der anderen Tour nicht stören;
|
||||
// nur den eigenen verspäteten Start verwerfen.
|
||||
if (_selectedTour.value != gapTour) {
|
||||
return@launch
|
||||
}
|
||||
// Nutzer hat während der Stille pausiert/gestoppt
|
||||
// (z. B. Pause-Tippen, Stop, neuer Single-Clip) → nicht starten.
|
||||
if (!_manualPlaying.value || manualPlayer.isSingleMode) {
|
||||
return@launch
|
||||
}
|
||||
if (pttManager.pttActive.value) {
|
||||
_manualPlaying.value = false
|
||||
stopProgressTicker(keepSnapshot = true)
|
||||
return@launch
|
||||
}
|
||||
val seqAfterGap = playableSequence()
|
||||
val resolvedIdx = seqAfterGap.indexOfFirst { it.compoundId == nextItem.compoundId }
|
||||
if (resolvedIdx < 0) {
|
||||
// Nächster Eintrag existiert nicht mehr → sauber stoppen.
|
||||
_manualPlaying.value = false
|
||||
_manualCurrentName.value = null
|
||||
_currentPlayable.value = null
|
||||
stopProgressTicker(keepSnapshot = false)
|
||||
musicManager.afterWaypointAudio()
|
||||
return@launch
|
||||
}
|
||||
val resolved = seqAfterGap[resolvedIdx]
|
||||
_currentPlayable.value = resolved
|
||||
_manualIndex.value = resolved.waypointIndex - 1
|
||||
playFromCurrentPlayable(autoAdvance = true)
|
||||
}
|
||||
}
|
||||
startProgressTicker()
|
||||
}
|
||||
@@ -1737,6 +2101,7 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
/** Stop manual playback completely (both playlist and single mode). */
|
||||
fun manualStop() {
|
||||
cancelManualGap()
|
||||
manualPlayer.stop()
|
||||
_manualPlaying.value = false
|
||||
_singlePlayingWaypointId.value = null
|
||||
@@ -1816,6 +2181,11 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
val wpName = waypoint.name.ifBlank { "Wegpunkt" }
|
||||
val displayName = if (clipTitle.isBlank()) wpName else "$wpName · $clipTitle"
|
||||
|
||||
// v2.7.37 — Einzelclip-Vorschau übernimmt: eine evtl. laufende
|
||||
// Auto-Advance-Stille der Tour-Sequenz abbrechen, sonst würde der
|
||||
// verzögerte Start die Vorschau überschreiben.
|
||||
cancelManualGap()
|
||||
|
||||
// Tour-Play beenden, falls aktiv.
|
||||
if (_manualPlaying.value && !manualPlayer.isSingleMode) {
|
||||
manualPlayer.stop()
|
||||
@@ -2362,5 +2732,12 @@ class WaypointViewModel(application: Application) : AndroidViewModel(application
|
||||
* Akku spürbar gegenüber häufigeren Ticks.
|
||||
*/
|
||||
private const val PROGRESS_TICK_MS = 500L
|
||||
|
||||
/**
|
||||
* v2.7.37 — Feste Stille-Pause vor dem automatischen Start des nächsten
|
||||
* Eintrags in der manuellen Sequenz-Wiedergabe (Clip→Clip und
|
||||
* Wegpunkt→Wegpunkt). Gibt der Wiedergabe ein hörbuchartiges Gefühl.
|
||||
*/
|
||||
private const val MANUAL_GAP_MS = 1700L
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user