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" } }