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