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 = 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 } } }