339 lines
12 KiB
Kotlin
339 lines
12 KiB
Kotlin
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
|
||
}
|
||
}
|
||
}
|