Release GPS2Audio v2.5.44 POI category fix

This commit is contained in:
Perplexity Computer
2026-05-30 05:42:44 +00:00
parent 18ea77b21d
commit 014e6c1bf1
100 changed files with 28584 additions and 1083 deletions
@@ -1,16 +1,8 @@
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
@@ -19,12 +11,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
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.
@@ -55,18 +44,27 @@ data class MusicPlaybackState(
)
/**
* Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer.
* Begleitmusik-Player ab v2.0.7 ein **dünner Facade-Wrapper** um
* [AtmoPlaybackService].
*
* 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)
* Architektur:
* - Die eigentliche ExoPlayer-Instanz lebt im Foreground-Service
* [AtmoPlaybackService]. Der Service wird gestartet, sobald
* [play] / [buildPlayer] aufgerufen werden, und bleibt am Leben, bis
* [stop] aufgerufen wird.
* - Der Service exponiert einen process-singleton StateFlow, den dieser
* Player über [playbackState] direkt weiterleitet. UI und ViewModel
* abonnieren denselben Flow.
* - Pause/Resume/Next/Previous/SetVolume gehen ausschließlich über
* Service-Intents die Activity-Lifecycle hat keinen Einfluss mehr
* auf die Wiedergabe.
*
* Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen.
* Bestehende öffentliche API (siehe v2.0.6) bleibt erhalten, damit Manager
* und ViewModel keine Anpassungen brauchen. [isPlaying] und [hasContent]
* werden jetzt aus dem letzten Snapshot des Service-StateFlows abgeleitet.
*
* Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen.
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
* Fade-/Duck-Animationen werden über kurze Koroutinen umgesetzt, die
* wiederholt [AtmoPlaybackService.setVolume] aufrufen.
*/
class BackgroundMusicPlayer(private val context: Context) {
@@ -76,49 +74,37 @@ class BackgroundMusicPlayer(private val context: Context) {
}
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
private var lastSetVolume: Float = 1.0f
// True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird
private var waypointSuppressed: Boolean = false
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
private var wasPlayingBeforeWaypoint: Boolean = false
// Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup)
private var loadedPlaylist: List<PlaylistItem> = emptyList()
/**
* State-Flow direkt vom Service. Wenn der Service noch nie gestartet wurde,
* enthält der Flow den leeren MusicPlaybackState (alle Felder default).
*/
val playbackState: StateFlow<MusicPlaybackState> = AtmoPlaybackService.state
// -----------------------------------------------------------------------
// Öffentlicher State-Flow
// Öffentliche Status-Getter (abgeleitet aus dem Service-Flow)
// -----------------------------------------------------------------------
private val _playbackState = MutableStateFlow(MusicPlaybackState())
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = playbackState.value.isPlaying
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean
get() = playbackState.value.hasContent
// -----------------------------------------------------------------------
// Ö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
@@ -126,73 +112,77 @@ class BackgroundMusicPlayer(private val context: Context) {
/**
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
* Baut ExoPlayer auf wenn nötig.
* Wenn der Service schon läuft mit gleicher Quelle: einfach Resume.
* Sonst: neue Quelle an Service übergeben (Service baut Player neu auf).
*/
fun play() {
val settings = currentSettings ?: return
if (!settings.enabled) return
val srcOk = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> settings.localPlaylist.isNotEmpty()
MusicSourceType.STREAM_URL -> settings.effectiveStreamPlaylist.isNotEmpty()
}
if (!srcOk) {
Log.w(TAG, "play(): keine Quelle konfiguriert")
return
}
if (player == null) {
buildPlayer(settings)
if (hasContent) {
// Service hat bereits einen Player Resume genügt.
AtmoPlaybackService.resume(context)
} else {
player?.play()
buildPlayer(settings)
}
}
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
fun pause() {
cancelFade()
player?.pause()
updateState()
AtmoPlaybackService.pause(context)
}
/** 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).
* Stoppt Wiedergabe komplett und beendet den Service.
* Service stoppt sich danach selbst (FGS-Notification verschwindet).
*/
fun stop() {
cancelFade()
AtmoPlaybackService.stop(context)
}
fun next() {
AtmoPlaybackService.next(context)
}
fun previous() {
AtmoPlaybackService.previous(context)
}
fun setVolume(volume: Float) {
val v = volume.coerceIn(0f, 1f)
lastSetVolume = v
AtmoPlaybackService.setVolume(context, v)
}
/**
* Gibt alle Ressourcen frei. Wird beim App-Shutdown aufgerufen.
* Der Service wird über [stop] beendet.
*/
fun release() {
scope.cancel()
AtmoPlaybackService.stop(context)
}
// -----------------------------------------------------------------------
// Wegpunkt-Interaktion (verändert sich nicht delegiert an Service via API)
// -----------------------------------------------------------------------
fun beforeWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) return
wasPlayingBeforeWaypoint = isPlaying
waypointSuppressed = true
// Nur wenn player vorhanden (Musik läuft/pausiert)
if (player == null) return
if (!hasContent) return
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
@@ -201,7 +191,7 @@ class BackgroundMusicPlayer(private val context: Context) {
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
fadeOut(settings.fadeDurationMs) {
player?.pause()
AtmoPlaybackService.pause(context)
}
}
}
@@ -211,16 +201,11 @@ class BackgroundMusicPlayer(private val context: Context) {
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
// Nichts tun Musik läuft auf normaler Lautstärke weiter
// Nichts tun.
}
}
}
/**
* 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
@@ -228,7 +213,6 @@ class BackgroundMusicPlayer(private val context: Context) {
}
val wasSuppressed = waypointSuppressed
waypointSuppressed = false
if (!wasSuppressed) return
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
@@ -236,15 +220,15 @@ class BackgroundMusicPlayer(private val context: Context) {
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) {
player?.play()
AtmoPlaybackService.resume(context)
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
player?.volume = 0f
player?.play()
AtmoPlaybackService.setVolume(context, 0f)
AtmoPlaybackService.resume(context)
fadeIn(settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
@@ -259,9 +243,7 @@ class BackgroundMusicPlayer(private val context: Context) {
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
if (shouldAutoStart) {
startOrPlay(settings)
}
if (shouldAutoStart) startOrPlay(settings)
}
}
}
@@ -270,186 +252,25 @@ class BackgroundMusicPlayer(private val context: Context) {
// Interner Aufbau
// -----------------------------------------------------------------------
/**
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
*/
private fun startOrPlay(settings: TourAudioSettings) {
if (player == null) {
if (!hasContent) {
buildPlayer(settings)
} else {
player?.play()
AtmoPlaybackService.resume(context)
}
}
/**
* Sendet die neue Quelle an den Service. Der Service wird ggf. gestartet
* (mit `startForegroundService`) und baut den ExoPlayer intern auf.
*/
private fun buildPlayer(settings: TourAudioSettings) {
releasePlayer()
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val items = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist)
MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl)
}
if (items.isEmpty()) {
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
return
}
// Playlist-Snapshot für Titel-Lookup speichern
loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist
val exo = ExoPlayer.Builder(context).build().also { p ->
p.setMediaItems(items)
p.repeatMode = Player.REPEAT_MODE_ALL
p.shuffleModeEnabled = settings.shuffle
p.volume = normalVolume
p.addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
updateState()
}
override fun onIsPlayingChanged(playing: Boolean) {
updateState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
}
})
p.prepare()
p.play()
}
player = exo
Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})")
startProgressPolling()
updateState()
}
/** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */
private fun startProgressPolling() {
progressJob?.cancel()
progressJob = scope.launch {
while (isActive) {
delay(500L)
updateState()
}
}
}
/** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */
private fun updateState() {
val p = player
val settings = currentSettings
if (p == null || settings == null) {
_playbackState.value = MusicPlaybackState()
return
}
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val idx = p.currentMediaItemIndex
val total = p.mediaItemCount
val playlist = loadedPlaylist
// Titel des aktuellen Tracks
val currentTitle: String = when {
isStream -> settings.streamUrl ?: ""
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
else -> ""
}
// Titel des nächsten Tracks
val nextTitle: String = when {
isStream -> ""
playlist.size > 1 -> {
val nextIdx = (idx + 1) % playlist.size
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
}
else -> ""
}
// Quell-Label
val sourceLabel: String = when {
isStream -> {
val url = settings.streamUrl ?: ""
runCatching {
java.net.URL(url).host.ifBlank { url }
}.getOrElse { url }
}
else -> "Playlist"
}
// Position und Dauer nur für lokale Dateien mit bekannter Länge
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
val durationMs = if (!isStream) {
val d = p.duration
if (d > 0) d else 0L
} else 0L
_playbackState.value = MusicPlaybackState(
isPlaying = p.isPlaying,
hasContent = true,
currentTitle = currentTitle,
sourceLabel = sourceLabel,
isStream = isStream,
positionMs = positionMs,
durationMs = durationMs,
playlistIndex = if (!isStream) idx else -1,
playlistTotal = if (!isStream) total else 0,
nextTitle = nextTitle,
supportsSkip = !isStream && total > 1
)
}
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
return playlist.mapNotNull { item ->
runCatching {
val uri = Uri.parse(item.uriString)
// Persistable URI-Berechtigung sichern (beste-Effort)
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
MediaItem.Builder()
.setUri(uri)
.build()
}.onFailure { e ->
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
}.getOrNull()
}
}
private fun buildStreamItems(streamUrl: String?): List<MediaItem> {
if (streamUrl.isNullOrBlank()) return emptyList()
val lower = streamUrl.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl")
return emptyList()
}
return listOf(
MediaItem.Builder()
.setUri(Uri.parse(streamUrl))
.build()
)
}
private fun releasePlayer() {
cancelFade()
progressJob?.cancel()
progressJob = null
runCatching {
player?.stop()
player?.release()
}
player = null
loadedPlaylist = emptyList()
_playbackState.value = MusicPlaybackState()
AtmoPlaybackService.start(context, settings)
Log.i(TAG, "BackgroundMusicPlayer.buildPlayer → Service gestartet (${settings.sourceType})")
}
// -----------------------------------------------------------------------
// Fade-Helfer (Koroutinen-basiert)
// Fade-Helfer
// -----------------------------------------------------------------------
private fun cancelFade() {
@@ -459,7 +280,7 @@ class BackgroundMusicPlayer(private val context: Context) {
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
cancelFade()
val startVolume = player?.volume ?: 1f
val startVolume = lastSetVolume.coerceIn(0f, 1f)
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = startVolume / steps
@@ -467,13 +288,13 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current - stepSize).coerceAtLeast(0f)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) {
player?.volume = 0f
onDone()
}
AtmoPlaybackService.setVolume(context, 0f)
lastSetVolume = 0f
onDone()
}
}
@@ -487,16 +308,18 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + stepSize).coerceAtMost(targetVolume)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
cancelFade()
val startVolume = player?.volume ?: normalVolume
val startVolume = lastSetVolume.coerceIn(0f, 1f)
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val delta = (targetVolume - startVolume) / steps
@@ -504,10 +327,12 @@ class BackgroundMusicPlayer(private val context: Context) {
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + delta).coerceIn(0f, 1f)
withContext(Dispatchers.Main) { player?.volume = current }
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
}