313 lines
11 KiB
Kotlin
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"
|
|
}
|
|
}
|