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

313 lines
11 KiB
Kotlin

package de.waypointaudio.service
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
import android.os.PowerManager
import android.util.Log
/**
* Dedicated audio player for manual (non-GPS) waypoint playback in the UI.
*
* Separate from [AudioPlayer] used by the location service so that manual
* playback and GPS-triggered playback do not interfere with each other.
*
* Supports two modes:
* - **Playlist mode**: play/pause/resume for the global manual player bar (prev/next work).
* - **Single mode**: triggered by a per-waypoint Play button. Plays exactly one file and
* stops on completion without advancing to any next item.
*
* Thread-safety: call only from a single thread (e.g. Main dispatcher).
*/
class ManualAudioPlayer {
private var mediaPlayer: MediaPlayer? = null
private var currentUri: String? = null
/** v2.5.10 — App-Context für FGS-Stop aus releasePlayer/stop heraus. */
private var lastAppContext: Context? = null
/**
* Whether the player is currently in single-item mode.
* In single mode, [playlistMode] is false and [singleWaypointId] holds the active waypoint ID.
*/
var isSingleMode: Boolean = false
private set
/** ID of the waypoint currently playing in single mode (null if not in single mode). */
var singleWaypointId: String? = null
private set
/** True while audio is actively playing (not paused, not stopped). */
val isPlaying: Boolean
get() = mediaPlayer?.isPlaying == true
/** True if a track is loaded (playing or paused). */
val hasTrack: Boolean
get() = mediaPlayer != null
/**
* v2.5.14 — Aktuelle Wiedergabeposition in Millisekunden. Liefert 0, wenn
* kein Player geladen ist oder die Position (noch) nicht ermittelbar ist.
*/
fun getCurrentPositionMs(): Long = runCatching {
mediaPlayer?.currentPosition?.toLong() ?: 0L
}.getOrDefault(0L)
/**
* v2.5.14 — Gesamtdauer der aktuell geladenen Datei in Millisekunden.
* Liefert -1, wenn unbekannt (Streams, noch nicht geprepared, oder bereits
* freigegeben). UI-Layer sollte einen `--:--`-Fallback rendern.
*/
fun getDurationMs(): Long = runCatching {
val d = mediaPlayer?.duration ?: -1
if (d <= 0) -1L else d.toLong()
}.getOrDefault(-1L)
// -------------------------------------------------------------------------
// Playlist mode (existing global manual player bar)
// -------------------------------------------------------------------------
/**
* Play or resume audio in **playlist mode**.
*
* If [soundUri] matches the currently loaded (possibly paused) track,
* this call resumes from the paused position. Otherwise a new track is loaded.
*
* Clears single mode if it was active.
*
* @param context Android context
* @param soundUri content:// or file:// URI string
* @param onCompletion called when playback finishes naturally
*/
fun play(
context: Context,
soundUri: String,
onCompletion: () -> Unit = {}
) {
if (soundUri.isBlank()) {
Log.w(TAG, "Keine Audiodatei angegeben.")
return
}
// Leaving single mode → clear single state
isSingleMode = false
singleWaypointId = null
// If same track is loaded and just paused → resume
if (soundUri == currentUri && mediaPlayer != null && !isPlaying) {
runCatching {
mediaPlayer?.start()
}.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen von $soundUri", e)
releasePlayer()
loadAndPlay(context, soundUri, onCompletion)
}
return
}
// New or different track → fresh load
releasePlayer()
loadAndPlay(context, soundUri, onCompletion)
}
// -------------------------------------------------------------------------
// Single-item mode (per-waypoint card Play button)
// -------------------------------------------------------------------------
/**
* Play a **single waypoint file** without any queue continuation.
*
* - Stops any currently active playback (playlist or single) first.
* - Sets [isSingleMode] = true and [singleWaypointId] = [waypointId].
* - On natural completion, clears both flags and calls [onCompletion].
* Does NOT advance to any next item.
* - If this waypoint is already playing in single mode, toggles pause/resume.
*
* @param context Android context
* @param waypointId Stable ID of the waypoint (for active-state tracking in UI)
* @param soundUri content:// or file:// URI string
* @param onCompletion called when playback finishes naturally (not on explicit stop/pause)
*/
fun playSingle(
context: Context,
waypointId: String,
soundUri: String,
onCompletion: () -> Unit = {}
) {
if (soundUri.isBlank()) {
Log.w(TAG, "Keine Audiodatei für Wegpunkt $waypointId angegeben.")
return
}
// Same waypoint already in single mode → toggle pause/resume
if (isSingleMode && singleWaypointId == waypointId && mediaPlayer != null) {
if (isPlaying) {
runCatching { mediaPlayer?.pause() }.onFailure { e ->
Log.e(TAG, "Fehler beim Pausieren (single mode) für $waypointId", e)
}
} else {
runCatching { mediaPlayer?.start() }.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen (single mode) für $waypointId", e)
releasePlayer()
startSingleLoad(context, waypointId, soundUri, onCompletion)
}
}
return
}
// Different waypoint or different mode → stop current and start fresh
releasePlayer()
startSingleLoad(context, waypointId, soundUri, onCompletion)
}
private fun startSingleLoad(
context: Context,
waypointId: String,
soundUri: String,
onCompletion: () -> Unit
) {
isSingleMode = true
singleWaypointId = waypointId
loadAndPlay(context, soundUri) {
// Natural completion: clear single state, then notify caller
isSingleMode = false
singleWaypointId = null
onCompletion()
}
}
// -------------------------------------------------------------------------
// Shared internal loading
// -------------------------------------------------------------------------
private fun loadAndPlay(
context: Context,
soundUri: String,
onCompletion: () -> Unit
) {
runCatching {
val uri = Uri.parse(soundUri)
mediaPlayer = MediaPlayer().apply {
// Grant URI permission for content:// URIs originating from SAF
setDataSource(context, uri)
// v2.5.10 — MediaPlayer-eigener WakeLock als zweites Sicherheitsnetz.
// Greift zusätzlich zum PARTIAL_WAKE_LOCK des ManualPlaybackService.
setWakeMode(context.applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
prepare()
start()
setOnCompletionListener {
releasePlayer()
onCompletion()
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "MediaPlayer-Fehler: what=$what extra=$extra")
mp.release()
releasePlayer()
true
}
}
currentUri = soundUri
lastAppContext = context.applicationContext
// v2.5.10 — Foreground-Service als Lebenshalter starten, sobald
// tatsächlich Wiedergabe läuft. Updates der Notification-Texte
// erfolgen aus dem ViewModel über setMeta/start.
ManualPlaybackService.start(
context.applicationContext,
title = pendingTitle.takeIf { it.isNotBlank() } ?: "GPS2Audio",
tour = pendingTour
)
}.onFailure { e ->
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
releasePlayer()
}
}
// v2.5.10 — Optionale Anzeige-Metadaten für die Foreground-Notification.
// Die ViewModel-Schicht setzt diese vor dem nächsten play()-Aufruf.
@Volatile private var pendingTitle: String = ""
@Volatile private var pendingTour: String = ""
/** Setzt Titel/Tour, die beim nächsten Start als Notification-Text erscheinen. */
fun setNextNotificationMeta(title: String?, tour: String?) {
pendingTitle = title.orEmpty()
pendingTour = tour.orEmpty()
}
/** Aktualisiert die Notification eines bereits laufenden ManualPlaybackService. */
fun updateNotificationMeta(context: Context, title: String?, tour: String?) {
pendingTitle = title.orEmpty()
pendingTour = tour.orEmpty()
if (mediaPlayer != null) {
ManualPlaybackService.updateMeta(
context.applicationContext, title, tour
)
}
}
// -------------------------------------------------------------------------
// Pause / Stop / Release
// -------------------------------------------------------------------------
/**
* Pause playback. Position is retained so [play] / [playSingle] can resume from here.
*/
fun pause() {
runCatching {
if (mediaPlayer?.isPlaying == true) {
mediaPlayer?.pause()
}
}.onFailure { e ->
Log.e(TAG, "Fehler beim Pausieren", e)
}
}
/**
* Resume a previously paused playback at the same position. Used by the
* PTT interruption flow to bring back a waypoint audio that was paused
* because the user pressed PTT. If the MediaPlayer has been released or is
* already playing, this is a no-op.
*/
fun resume() {
runCatching {
val mp = mediaPlayer ?: return
if (!mp.isPlaying) mp.start()
}.onFailure { e ->
Log.e(TAG, "Fehler beim Fortsetzen", e)
}
}
/**
* Stop and release resources. Clears current track and single-mode state.
*/
fun stop() {
releasePlayer()
}
/**
* Release MediaPlayer resources. Called on ViewModel cleared.
*/
fun release() {
releasePlayer()
}
private fun releasePlayer() {
runCatching {
mediaPlayer?.let {
if (it.isPlaying) it.stop()
it.release()
}
}
mediaPlayer = null
currentUri = null
isSingleMode = false
singleWaypointId = null
// v2.5.10 — Foreground-Service stoppen, sobald keine manuelle
// Wiedergabe mehr existiert. WakeLock wird vom Service freigegeben.
lastAppContext?.let { ctx -> ManualPlaybackService.stop(ctx) }
}
companion object {
private const val TAG = "ManualAudioPlayer"
}
}