Files
GPS2Audio/app/src/main/kotlin/de/waypointaudio/service/BackgroundMusicPlayer.kt
T
2026-05-30 05:42:44 +00:00

339 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package de.waypointaudio.service
import android.content.Context
import android.util.Log
import de.waypointaudio.data.MusicSourceType
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.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* 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 ab v2.0.7 ein **dünner Facade-Wrapper** um
* [AtmoPlaybackService].
*
* 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.
*
* 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.
*
* Fade-/Duck-Animationen werden über kurze Koroutinen umgesetzt, die
* wiederholt [AtmoPlaybackService.setVolume] aufrufen.
*/
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 fadeJob: Job? = null
private var currentSettings: TourAudioSettings? = null
private var normalVolume: Float = 1.0f
private var lastSetVolume: Float = 1.0f
private var waypointSuppressed: Boolean = false
private var wasPlayingBeforeWaypoint: Boolean = false
/**
* 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
// -----------------------------------------------------------------------
// Öffentliche Status-Getter (abgeleitet aus dem Service-Flow)
// -----------------------------------------------------------------------
/** 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
// -----------------------------------------------------------------------
fun loadSettings(settings: TourAudioSettings) {
currentSettings = settings
normalVolume = 1.0f
}
/**
* Startet Wiedergabe mit den aktuell geladenen Einstellungen.
* 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 (hasContent) {
// Service hat bereits einen Player Resume genügt.
AtmoPlaybackService.resume(context)
} else {
buildPlayer(settings)
}
}
/** Pausiert Wiedergabe ohne Ressourcen freizugeben. */
fun pause() {
cancelFade()
AtmoPlaybackService.pause(context)
}
/**
* 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
if (!hasContent) return
when (settings.behavior) {
WaypointMusicBehavior.PAUSE_RESUME -> {
if (wasPlayingBeforeWaypoint) pause()
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
fadeOut(settings.fadeDurationMs) {
AtmoPlaybackService.pause(context)
}
}
}
WaypointMusicBehavior.DUCK_UNDERLAY -> {
if (wasPlayingBeforeWaypoint) {
fadeToVolume(settings.duckVolume, settings.fadeDurationMs)
}
}
WaypointMusicBehavior.CONTINUE_UNDERLAY -> {
// Nichts tun.
}
}
}
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) {
AtmoPlaybackService.resume(context)
} else if (shouldAutoStart) {
startOrPlay(settings)
}
}
WaypointMusicBehavior.FADE_OUT_IN -> {
if (wasPlayingBeforeWaypoint) {
AtmoPlaybackService.setVolume(context, 0f)
AtmoPlaybackService.resume(context)
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
// -----------------------------------------------------------------------
private fun startOrPlay(settings: TourAudioSettings) {
if (!hasContent) {
buildPlayer(settings)
} else {
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) {
AtmoPlaybackService.start(context, settings)
Log.i(TAG, "BackgroundMusicPlayer.buildPlayer → Service gestartet (${settings.sourceType})")
}
// -----------------------------------------------------------------------
// Fade-Helfer
// -----------------------------------------------------------------------
private fun cancelFade() {
fadeJob?.cancel()
fadeJob = null
}
private fun fadeOut(durationMs: Long, onDone: () -> Unit) {
cancelFade()
val startVolume = lastSetVolume.coerceIn(0f, 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)
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
AtmoPlaybackService.setVolume(context, 0f)
lastSetVolume = 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)
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
private fun fadeToVolume(targetVolume: Float, durationMs: Long) {
cancelFade()
val startVolume = lastSetVolume.coerceIn(0f, 1f)
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)
AtmoPlaybackService.setVolume(context, current)
lastSetVolume = current
delay(FADE_STEP_INTERVAL_MS)
}
AtmoPlaybackService.setVolume(context, targetVolume)
lastSetVolume = targetVolume
}
}
}