Initial release of GPS2Audio

This commit is contained in:
Marcel Mayer
2026-05-05 20:54:05 +02:00
commit b2aa1c167c
48 changed files with 11570 additions and 0 deletions
@@ -0,0 +1,239 @@
package de.waypointaudio.service
import android.content.Context
import android.media.MediaPlayer
import android.net.Uri
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
/**
* 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
// -------------------------------------------------------------------------
// 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)
prepare()
start()
setOnCompletionListener {
releasePlayer()
onCompletion()
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "MediaPlayer-Fehler: what=$what extra=$extra")
mp.release()
releasePlayer()
true
}
}
currentUri = soundUri
}.onFailure { e ->
Log.e(TAG, "Fehler beim Abspielen von $soundUri", e)
releasePlayer()
}
}
// -------------------------------------------------------------------------
// 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)
}
}
/**
* 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
}
companion object {
private const val TAG = "ManualAudioPlayer"
}
}