Initial release of GPS2Audio
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user