Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,513 @@
package de.waypointaudio.service
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import de.waypointaudio.data.MusicSourceType
import de.waypointaudio.data.PlaylistItem
import de.waypointaudio.data.TourAudioSettings
import de.waypointaudio.data.WaypointMusicBehavior
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Zustand des Begleitmusik-Players, der in der UI angezeigt werden kann.
*/
data class MusicPlaybackState(
/** Ob gerade Musik abgespielt wird. */
val isPlaying: Boolean = false,
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean = false,
/** Titel des aktuellen Tracks (displayName oder URL). */
val currentTitle: String = "",
/** Kurzform der Quelle (z. B. "Playlist" oder Stream-URL-Hostname). */
val sourceLabel: String = "",
/** Ob die Quelle ein Stream ist (keine endliche Länge). */
val isStream: Boolean = false,
/** Wiedergabe-Position in Millisekunden (0 für Streams). */
val positionMs: Long = 0L,
/** Gesamtlänge in Millisekunden (0 für Streams oder unbekannt). */
val durationMs: Long = 0L,
/** Index des aktuellen Tracks in der Playlist (0-basiert, -1 wenn unbekannt). */
val playlistIndex: Int = -1,
/** Gesamtanzahl Tracks in der Playlist. */
val playlistTotal: Int = 0,
/** Titel des nächsten Tracks (leer wenn nicht verfügbar). */
val nextTitle: String = "",
/** Ob Previous/Next sinnvoll sind (nur lokale Playlists mit >1 Track). */
val supportsSkip: Boolean = false
)
/**
* Begleitmusik-Player auf Basis von AndroidX Media3 ExoPlayer.
*
* Unterstützt:
* - Lokale Playlists via content:// URIs (persistable permissions werden beim Abspielen gesichert)
* - Direkte HTTP/HTTPS Audio-Stream-URLs
* - Fade-Out/In, Duck (Lautstärke reduzieren), Pause/Resume, Weiter-Spielen
* - Optionaler Auto-Start nach Wegpunkt-Audio (autoStartAfterWaypoint)
*
* Lifecycle: Instanz an ViewModel oder Application koppeln; [release] beim Aufräumen aufrufen.
*
* Thread-Sicherheit: ExoPlayer-Zugriffe müssen auf dem Main-Thread erfolgen.
* Alle öffentlichen Methoden müssen aus dem Main-Dispatcher aufgerufen werden.
*/
class BackgroundMusicPlayer(private val context: Context) {
companion object {
private const val TAG = "BackgroundMusicPlayer"
private const val FADE_STEP_INTERVAL_MS = 50L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: ExoPlayer? = null
private var fadeJob: Job? = null
private var progressJob: Job? = null
// Aktuell geladene Einstellungen
private var currentSettings: TourAudioSettings? = null
// Ziel-Lautstärke im Normalbetrieb (vor Duck/Fade)
private var normalVolume: Float = 1.0f
// True wenn Musik gerade wegen Wegpunkt-Audio unterdrückt wird
private var waypointSuppressed: Boolean = false
// True wenn Player war aktiv bevor Waypoint ausgelöst wurde
private var wasPlayingBeforeWaypoint: Boolean = false
// Snapshot-Liste der geladenen PlaylistItems (für Titel-Lookup)
private var loadedPlaylist: List<PlaylistItem> = emptyList()
// -----------------------------------------------------------------------
// Öffentlicher State-Flow
// -----------------------------------------------------------------------
private val _playbackState = MutableStateFlow(MusicPlaybackState())
/** Aktueller Wiedergabe-Zustand; sammle diesen Flow in der UI. */
val playbackState: StateFlow<MusicPlaybackState> = _playbackState.asStateFlow()
// -----------------------------------------------------------------------
// Öffentliche API
// -----------------------------------------------------------------------
/** Ob gerade Musik abgespielt wird (nicht pausiert/gestoppt). */
val isPlaying: Boolean
get() = player?.isPlaying == true
/** Ob ein Track geladen ist (auch wenn pausiert). */
val hasContent: Boolean
get() = player != null
/**
* Lädt neue Einstellungen und bereitet den Player vor.
* Stoppt zuvor laufende Wiedergabe wenn Quelle sich geändert hat.
*/
fun loadSettings(settings: TourAudioSettings) {
currentSettings = settings
normalVolume = 1.0f
}
/**
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
* Baut ExoPlayer auf wenn nötig.
*/
fun play() {
val settings = currentSettings ?: return
if (!settings.enabled) return
if (player == null) {
buildPlayer(settings)
} else {
player?.play()
}
}
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
fun pause() {
cancelFade()
player?.pause()
updateState()
}
/** Stoppt Wiedergabe und gibt ExoPlayer frei. */
fun stop() {
cancelFade()
releasePlayer()
updateState()
}
/** Nächster Titel in der Playlist (nur für lokale Playlists sinnvoll). */
fun next() {
val p = player ?: return
if (p.hasNextMediaItem()) p.seekToNextMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Vorheriger Titel in der Playlist. */
fun previous() {
val p = player ?: return
if (p.hasPreviousMediaItem()) p.seekToPreviousMediaItem() else p.seekTo(0, 0)
updateState()
}
/** Setzt die Lautstärke sofort (0.0 1.0). */
fun setVolume(volume: Float) {
player?.volume = volume.coerceIn(0f, 1f)
}
/** Gibt alle Ressourcen frei. Nach diesem Aufruf ist die Instanz nicht mehr nutzbar. */
fun release() {
scope.cancel()
releasePlayer()
}
// -----------------------------------------------------------------------
// Wegpunkt-Interaktion
// -----------------------------------------------------------------------
/**
* Vor dem Abspielen eines Wegpunkt-Audios aufrufen.
* Wendet das konfigurierte Verhalten an (Pause, Fade-Out, Duck, Weiter).
*/
fun beforeWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) return
wasPlayingBeforeWaypoint = isPlaying
waypointSuppressed = true
// Nur wenn player vorhanden (Musik läuft/pausiert)
if (player == null) return
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) pause()
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
fadeOut(settings.fadeDurationMs) {
player?.pause()
}
}
}
WaypointMusicBehavior.DUCK_UNDERLAY -> {
if (wasPlayingBeforeWaypoint) {
fadeToVolume(settings.duckVolume, settings.fadeDurationMs)
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
// Nichts tun Musik läuft auf normaler Lautstärke weiter
}
}
}
/**
* Nach dem Abspielen eines Wegpunkt-Audios aufrufen.
* Stellt vorherigen Zustand wieder her.
* Wenn autoStartAfterWaypoint aktiv und Musik war nicht spielend, wird sie gestartet.
*/
fun afterWaypointAudio(settings: TourAudioSettings) {
if (!settings.enabled) {
waypointSuppressed = false
return
}
val wasSuppressed = waypointSuppressed
waypointSuppressed = false
if (!wasSuppressed) return
val shouldAutoStart = settings.autoStartAfterWaypoint && !wasPlayingBeforeWaypoint
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) {
player?.play()
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
player?.volume = 0f
player?.play()
fadeIn(settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
fadeIn(settings.fadeDurationMs)
}
}
WaypointMusicBehavior.DUCK_UNDERLAY -> {
if (wasPlayingBeforeWaypoint) {
fadeToVolume(normalVolume, settings.fadeDurationMs)
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
if (shouldAutoStart) {
startOrPlay(settings)
}
}
}
}
// -----------------------------------------------------------------------
// Interner Aufbau
// -----------------------------------------------------------------------
/**
* Startet Wiedergabe: baut Player neu wenn noch nicht vorhanden, sonst play().
*/
private fun startOrPlay(settings: TourAudioSettings) {
if (player == null) {
buildPlayer(settings)
} else {
player?.play()
}
}
private fun buildPlayer(settings: TourAudioSettings) {
releasePlayer()
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val items = when (settings.sourceType) {
MusicSourceType.LOCAL_PLAYLIST -> buildLocalItems(settings.localPlaylist)
MusicSourceType.STREAM_URL -> buildStreamItems(settings.streamUrl)
}
if (items.isEmpty()) {
Log.w(TAG, "Keine abspielbaren Quellen gefunden")
return
}
// Playlist-Snapshot für Titel-Lookup speichern
loadedPlaylist = if (isStream) emptyList() else settings.localPlaylist
val exo = ExoPlayer.Builder(context).build().also { p ->
p.setMediaItems(items)
p.repeatMode = Player.REPEAT_MODE_ALL
p.shuffleModeEnabled = settings.shuffle
p.volume = normalVolume
p.addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e(TAG, "ExoPlayer Fehler: ${error.message}", error)
updateState()
}
override fun onIsPlayingChanged(playing: Boolean) {
updateState()
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
updateState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updateState()
}
})
p.prepare()
p.play()
}
player = exo
Log.i(TAG, "BackgroundMusicPlayer gestartet (${settings.sourceType})")
startProgressPolling()
updateState()
}
/** Startet ein Coroutine-Polling für die Wiedergabe-Position (lokale Dateien). */
private fun startProgressPolling() {
progressJob?.cancel()
progressJob = scope.launch {
while (isActive) {
delay(500L)
updateState()
}
}
}
/** Liest den aktuellen Player-Zustand und schreibt ihn in [_playbackState]. */
private fun updateState() {
val p = player
val settings = currentSettings
if (p == null || settings == null) {
_playbackState.value = MusicPlaybackState()
return
}
val isStream = settings.sourceType == MusicSourceType.STREAM_URL
val idx = p.currentMediaItemIndex
val total = p.mediaItemCount
val playlist = loadedPlaylist
// Titel des aktuellen Tracks
val currentTitle: String = when {
isStream -> settings.streamUrl ?: ""
playlist.isNotEmpty() && idx in playlist.indices -> playlist[idx].displayName
else -> ""
}
// Titel des nächsten Tracks
val nextTitle: String = when {
isStream -> ""
playlist.size > 1 -> {
val nextIdx = (idx + 1) % playlist.size
if (nextIdx in playlist.indices) playlist[nextIdx].displayName else ""
}
else -> ""
}
// Quell-Label
val sourceLabel: String = when {
isStream -> {
val url = settings.streamUrl ?: ""
runCatching {
java.net.URL(url).host.ifBlank { url }
}.getOrElse { url }
}
else -> "Playlist"
}
// Position und Dauer nur für lokale Dateien mit bekannter Länge
val positionMs = if (!isStream) p.currentPosition.coerceAtLeast(0L) else 0L
val durationMs = if (!isStream) {
val d = p.duration
if (d > 0) d else 0L
} else 0L
_playbackState.value = MusicPlaybackState(
isPlaying = p.isPlaying,
hasContent = true,
currentTitle = currentTitle,
sourceLabel = sourceLabel,
isStream = isStream,
positionMs = positionMs,
durationMs = durationMs,
playlistIndex = if (!isStream) idx else -1,
playlistTotal = if (!isStream) total else 0,
nextTitle = nextTitle,
supportsSkip = !isStream && total > 1
)
}
private fun buildLocalItems(playlist: List<PlaylistItem>): List<MediaItem> {
return playlist.mapNotNull { item ->
runCatching {
val uri = Uri.parse(item.uriString)
// Persistable URI-Berechtigung sichern (beste-Effort)
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
MediaItem.Builder()
.setUri(uri)
.build()
}.onFailure { e ->
Log.e(TAG, "Ungültige URI: ${item.uriString}", e)
}.getOrNull()
}
}
private fun buildStreamItems(streamUrl: String?): List<MediaItem> {
if (streamUrl.isNullOrBlank()) return emptyList()
val lower = streamUrl.lowercase()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
Log.w(TAG, "Stream-URL ist keine gültige http/https-URL: $streamUrl")
return emptyList()
}
return listOf(
MediaItem.Builder()
.setUri(Uri.parse(streamUrl))
.build()
)
}
private fun releasePlayer() {
cancelFade()
progressJob?.cancel()
progressJob = null
runCatching {
player?.stop()
player?.release()
}
player = null
loadedPlaylist = emptyList()
_playbackState.value = MusicPlaybackState()
}
// -----------------------------------------------------------------------
// Fade-Helfer (Koroutinen-basiert)
// -----------------------------------------------------------------------
private fun cancelFade() {
fadeJob?.cancel()
fadeJob = null
}
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
cancelFade()
val startVolume = player?.volume ?: 1f
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = startVolume / steps
var current = startVolume
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current - stepSize).coerceAtLeast(0f)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) {
player?.volume = 0f
onDone()
}
}
}
private fun fadeIn(durationMs: Long) {
cancelFade()
val targetVolume = normalVolume
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val stepSize = targetVolume / steps
var current = 0f
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + stepSize).coerceAtMost(targetVolume)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
}
}
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
cancelFade()
val startVolume = player?.volume ?: normalVolume
fadeJob = scope.launch {
val steps = (durationMs / FADE_STEP_INTERVAL_MS).coerceAtLeast(1)
val delta = (targetVolume - startVolume) / steps
var current = startVolume
repeat(steps.toInt()) {
if (!isActive) return@launch
current = (current + delta).coerceIn(0f, 1f)
withContext(Dispatchers.Main) { player?.volume = current }
delay(FADE_STEP_INTERVAL_MS)
}
withContext(Dispatchers.Main) { player?.volume = targetVolume }
}
}
}