Playback you can always stop + release build + storage-access prompt

Phantom audio (playing with no UI and no way to stop it) is gone:
- New PlaybackController owns the single MediaPlayer process-wide.
  Home rows and details mirror the same state; a generation counter
  ignores stale async-prepare callbacks from rapid re-taps.
- Ongoing 'Now playing' notification with a Stop action works even
  with the app closed (receiver clears card + releases player).
- Details keeps seek/speed UI on top of the shared player.

Fluidity (measured on-device, release build):
- StrictMode caught 192ms DateFormat disk I/O inside composition
  (details info card) + library scans on the main thread. Date is
  pre-formatted in the ViewModel on IO; scans moved to Dispatchers.IO.
- Detail-open on release: 84 frames, 1 janky (1.2%), p99 31ms
  (was 6-8 janky, p99 ~130ms on debug).

Also: storage-access prompt card (fresh installs lose the file
grant; guides to system Settings), release signing via local
keystore (passwords in ~/.gradle/gradle.properties, key ignored
by git), default save name is now 'Recording - <date>'.
This commit is contained in:
avi 2026-09-14 14:30:04 -05:00
commit d68aab5336
7 changed files with 432 additions and 142 deletions

View file

@ -40,6 +40,9 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="microphone" /> android:foregroundServiceType="microphone" />
<receiver
android:name=".recording.PlaybackStopReceiver"
android:exported="false" />
<receiver <receiver
android:name=".widget.ShonarRecordWidget" android:name=".widget.ShonarRecordWidget"
android:exported="false" android:exported="false"

View file

@ -2,6 +2,8 @@ package com.shonar
import android.app.Application import android.app.Application
import androidx.room.Room import androidx.room.Room
import com.shonar.recording.PlaybackController
import com.shonar.recording.PlaybackOwner
import com.shonar.recording.RecordingRepository import com.shonar.recording.RecordingRepository
import com.shonar.recording.ShonarDatabase import com.shonar.recording.ShonarDatabase
import com.shonar.settings.DataStoreSettingsStore import com.shonar.settings.DataStoreSettingsStore
@ -17,10 +19,13 @@ import kotlinx.coroutines.launch
* servers, no background uploads. File sync (if wanted) is the user's own * servers, no background uploads. File sync (if wanted) is the user's own
* tooling outside this app. * tooling outside this app.
*/ */
class ShonarApplication : Application() { class ShonarApplication : Application(), PlaybackOwner {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** The single audio player (Home + details + shade Stop share it). */
override val playback: PlaybackController by lazy { PlaybackController(this) }
val database: ShonarDatabase by lazy { val database: ShonarDatabase by lazy {
Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db") Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db")
.addMigrations( .addMigrations(

View file

@ -0,0 +1,328 @@
package com.shonar.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Build
import androidx.core.app.NotificationCompat
import com.shonar.MainActivity
import com.shonar.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* The single audio player for the whole app. Home rows and the details
* screen share it, so what you hear is always what the UI shows — no
* phantom playback with no stop control.
*
* - One MediaPlayer at a time; a generation counter ignores stale async
* callbacks (tap A while B is still preparing, etc.).
* - Position polling (250ms) and speed live here; both screens bind it.
* - An ongoing "Now playing" notification carries a Stop action, so audio
* can always be silenced from the shade even with the app closed.
* - Audio-focus loss (call, assistant) pauses; never auto-resumes.
*/
class PlaybackController(private val app: Context) {
data class State(
val id: String,
val title: String,
val playing: Boolean,
val positionMs: Long,
val durationMs: Long,
val speed: Float,
val error: String? = null,
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<State?>(null)
val state: StateFlow<State?> = _state.asStateFlow()
private var player: MediaPlayer? = null
private var generation = 0
private var poll: Job? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
companion object {
const val ACTION_STOP = "com.shonar.playback.STOP"
const val CHANNEL_ID = "shonar_playback"
const val NOTIFICATION_ID = 1002
val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f)
fun stopIntent(ctx: Context): PendingIntent {
val i = Intent(ctx, PlaybackStopReceiver::class.java).setAction(ACTION_STOP)
return PendingIntent.getBroadcast(
ctx, 61, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
/** Play/pause toggle for [id]. Anything else playing stops first. */
fun toggle(id: String, title: String, filePath: String) {
val cur = _state.value
if (cur != null && cur.id == id) {
val p = player
if (p != null && cur.playing) {
pauseLocked()
} else if (p != null) {
runCatching { applySpeed(p, cur.speed); p.start() }
_state.value = cur.copy(playing = true, error = null)
startPolling()
pushNotification()
}
return
}
startNew(id, title, filePath, cur?.speed ?: 1f)
}
fun seekTo(positionMs: Long) {
val cur = _state.value ?: return
val clamped = positionMs.coerceIn(0, cur.durationMs.coerceAtLeast(1))
runCatching { player?.seekTo(clamped.toInt()) }
_state.value = cur.copy(positionMs = clamped)
}
fun cycleSpeed() {
val cur = _state.value ?: return
val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
player?.let { applySpeed(it, next) }
_state.value = cur.copy(speed = next)
}
fun stop() {
generation++
poll?.cancel()
poll = null
runCatching {
player?.apply {
runCatching { stop() }
release()
}
}
player = null
abandonFocus()
_state.value = null
cancelNotification()
}
/** Call when the owning process goes down for good (tests). */
fun release() {
stop()
scope.cancel()
}
// ---- internals --------------------------------------------------------
private fun startNew(id: String, title: String, filePath: String, speed: Float) {
val gen = ++generation
poll?.cancel()
poll = null
runCatching { player?.release() }
player = null
val mp: MediaPlayer
try {
mp = MediaPlayer().apply {
setDataSource(filePath)
setOnPreparedListener { prepared ->
if (gen != generation) {
runCatching { prepared.release() }
return@setOnPreparedListener
}
requestFocus()
applySpeed(prepared, speed)
runCatching { prepared.start() }
val dur = runCatching { prepared.duration.toLong() }.getOrDefault(1L)
.coerceAtLeast(1)
_state.value = State(id, title, playing = true, 0, dur, speed)
startPolling()
pushNotification()
}
setOnCompletionListener {
if (gen != generation) {
runCatching { it.release() }
return@setOnCompletionListener
}
stop()
}
setOnErrorListener { _, _, _ ->
if (gen == generation) {
_state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
cancelNotification()
}
true
}
prepareAsync()
}
} catch (_: Exception) {
if (gen == generation) {
_state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
}
return
}
player = mp
// Tentative row state so a second tap lands on this session, not a new one.
_state.value = State(id, title, false, 0, 1, speed)
}
private fun pauseLocked() {
val cur = _state.value ?: return
val p = player ?: return
runCatching { if (p.isPlaying) p.pause() }
poll?.cancel()
poll = null
_state.value = cur.copy(playing = false, positionMs = currentPosition())
pushNotification()
}
private fun currentPosition(): Long =
runCatching { player?.currentPosition?.toLong() }.getOrNull()
?: _state.value?.positionMs ?: 0L
private fun applySpeed(p: MediaPlayer, speed: Float) {
runCatching {
val params = if (Build.VERSION.SDK_INT >= 23) {
p.playbackParams ?: android.media.PlaybackParams()
} else {
return
}
p.playbackParams = params.apply { this.speed = speed }
}
}
private fun startPolling() {
poll?.cancel()
poll = scope.launch {
while (isActive) {
delay(250)
val p = player ?: break
if (!runCatching { p.isPlaying }.getOrDefault(false)) break
val cur = _state.value ?: break
_state.value = cur.copy(
positionMs = runCatching { p.currentPosition.toLong() }.getOrDefault(cur.positionMs),
durationMs = runCatching { p.duration.toLong() }.getOrDefault(cur.durationMs).coerceAtLeast(1),
)
}
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseLocked()
else -> Unit
}
}
private fun requestFocus() {
val am = app.getSystemService(AudioManager::class.java) ?: return
audioManager = am
if (Build.VERSION.SDK_INT >= 26) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(focusListener)
.build()
focusRequest = req
runCatching { am.requestAudioFocus(req) }
} else {
@Suppress("DEPRECATION")
runCatching { am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) }
}
}
private fun abandonFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } }
focusRequest = null
} else {
@Suppress("DEPRECATION")
runCatching { am.abandonAudioFocus(focusListener) }
}
audioManager = null
}
// ---- notification -----------------------------------------------------
private fun notifications(): NotificationManager =
app.getSystemService(NotificationManager::class.java)
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = notifications()
if (runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() == null) {
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW)
)
}
}
private fun pushNotification() {
val s = _state.value ?: return cancelNotification()
ensureChannel()
val openApp = PendingIntent.getActivity(
app, 60,
Intent(app, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notif: Notification = NotificationCompat.Builder(app, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle(if (s.playing) "Playing" else "Paused")
.setContentText(s.title)
.setOngoing(s.playing)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.addAction(R.drawable.ic_notif_close, "Stop", stopIntent(app))
.build()
runCatching { notifications().notify(NOTIFICATION_ID, notif) }
}
private fun cancelNotification() {
runCatching { notifications().cancel(NOTIFICATION_ID) }
}
}
/** Shade "Stop" button: silences playback even with the app closed. */
class PlaybackStopReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != PlaybackController.ACTION_STOP) return
val app = context.applicationContext
// Route through the shared controller when the process is alive…
runCatching { (app as? PlaybackOwner)?.playback?.stop() }
// …and always clear the notification (covers a restarted process
// holding no player while the card is still posted).
runCatching {
(app.getSystemService(NotificationManager::class.java) as NotificationManager)
.cancel(PlaybackController.NOTIFICATION_ID)
}
}
}
/** Implemented by [com.shonar.ShonarApplication]: one controller per process. */
interface PlaybackOwner {
val playback: PlaybackController
}

View file

@ -45,6 +45,15 @@ class RecordingRepository(
importExistingFiles() importExistingFiles()
} }
/** False when the OS hasn't granted file access (fresh install) — the
* library stays empty until the user flips it on in system Settings. */
suspend fun libraryAccessible(): Boolean = withContext(Dispatchers.IO) {
runCatching {
val root = runCatching { rootResolver?.invoke() }.getOrNull() ?: libraryRoot
root.exists() && root.canRead()
}.getOrDefault(false)
}
/** /**
* Adopt audio files already sitting in the library folder (put there by * Adopt audio files already sitting in the library folder (put there by
* the user's own file sync, USB, …). Flat scan, top level only; * the user's own file sync, USB, …). Flat scan, top level only;

View file

@ -1,14 +1,11 @@
package com.shonar.ui.detail package com.shonar.ui.detail
import android.media.MediaPlayer
import android.media.PlaybackParams
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication import com.shonar.ShonarApplication
import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity import com.shonar.recording.RecordingEntity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -46,11 +43,8 @@ class DetailsViewModel(
private val _state = MutableStateFlow<UiState>(UiState.Loading) private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow() val state: StateFlow<UiState> = _state.asStateFlow()
private var player: MediaPlayer? = null
private var pollJob: Job? = null
companion object { companion object {
val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f) val SPEEDS = PlaybackController.SPEEDS
fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory = fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory =
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@ -70,6 +64,26 @@ class DetailsViewModel(
init { init {
refresh() refresh()
// Mirror the shared player: what you hear is what this screen shows,
// even if playback started from the Home list.
viewModelScope.launch {
app.playback.state.collect { ps ->
val cur = current() ?: return@collect
if (ps == null || ps.id != recordingId) {
_state.value = UiState.ReadyState(cur.copy(playing = false))
} else {
_state.value = UiState.ReadyState(
cur.copy(
playing = ps.playing,
positionMs = ps.positionMs,
durationMs = ps.durationMs,
speed = ps.speed,
playbackError = ps.error,
)
)
}
}
}
} }
fun refresh() { fun refresh() {
@ -115,120 +129,20 @@ class DetailsViewModel(
_state.value = UiState.ReadyState(cur.copy(saveError = null)) _state.value = UiState.ReadyState(cur.copy(saveError = null))
} }
// ---- playback ------------------------------------------------------------ // ---- playback (shared controller; this screen only mirrors it) ----
fun togglePlayback() { fun togglePlayback() {
val cur = current() ?: return val cur = current() ?: return
val p = player app.playback.toggle(cur.recording.id, cur.recording.title, cur.recording.filePath)
if (p != null) {
if (p.isPlaying) {
p.pause()
stopPolling()
_state.value = UiState.ReadyState(cur.copy(playing = false, positionMs = p.currentPosition.toLong()))
} else {
applySpeed(p, cur.speed)
p.start()
startPolling()
_state.value = UiState.ReadyState(cur.copy(playing = true))
}
return
}
viewModelScope.launch {
val fresh = (current() ?: return@launch).copy(playbackError = null)
_state.value = UiState.ReadyState(fresh)
val mp = runCatching {
MediaPlayer().apply {
setDataSource(fresh.recording.filePath)
setOnPreparedListener { prepared ->
applySpeed(prepared, fresh.speed)
prepared.start()
startPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(
playing = true,
durationMs = prepared.duration.toLong().coerceAtLeast(1),
)
)
}
setOnCompletionListener {
stopPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(playing = false, positionMs = 0)
)
it.seekTo(0)
}
setOnErrorListener { _, _, _ ->
stopPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(
playing = false,
playbackError = "Could not play this file.",
)
)
true
}
prepareAsync()
}
}.getOrNull()
if (mp == null) {
_state.value = UiState.ReadyState(fresh.copy(playbackError = "Could not play this file."))
} else {
player = mp
}
}
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
val cur = current() ?: return app.playback.seekTo(positionMs)
val duration = cur.durationMs.coerceAtLeast(1)
val clamped = positionMs.coerceIn(0, duration)
player?.seekTo(clamped.toInt())
_state.value = UiState.ReadyState(cur.copy(positionMs = clamped))
} }
fun cycleSpeed() { fun cycleSpeed() {
val cur = current() ?: return app.playback.cycleSpeed()
val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
player?.let { applySpeed(it, next) }
_state.value = UiState.ReadyState(cur.copy(speed = next))
}
private fun applySpeed(p: MediaPlayer, speed: Float) {
runCatching {
p.playbackParams = (p.playbackParams ?: PlaybackParams()).apply { this.speed = speed }
}
}
private fun startPolling() {
pollJob?.cancel()
pollJob = viewModelScope.launch {
while (true) {
delay(250)
val p = player ?: break
if (!p.isPlaying) break
val cur = current() ?: break
_state.value = UiState.ReadyState(
cur.copy(
positionMs = p.currentPosition.toLong(),
durationMs = runCatching { p.duration.toLong() }
.getOrDefault(cur.durationMs).coerceAtLeast(1),
)
)
}
}
}
private fun stopPolling() {
pollJob?.cancel()
pollJob = null
} }
private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready
override fun onCleared() {
stopPolling()
player?.release()
player = null
super.onCleared()
}
} }

View file

@ -68,6 +68,25 @@ fun HomeScreen(
val notice by vm.notice.collectAsState() val notice by vm.notice.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) } var consentSeen by remember { mutableStateOf<Boolean?>(null) }
var storageOk by remember { mutableStateOf<Boolean?>(null) }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Re-check file access on every return (user may have just flipped it
// on in system Settings).
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
scope.launch {
app.settingsManager.ensureLoaded()
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
runCatching { app.recordingRepository.refreshRoot() }
storageOk = runCatching { app.recordingRepository.libraryAccessible() }
.getOrDefault(true)
}
}
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) vm.start() } ) { granted -> if (granted) vm.start() }
@ -87,6 +106,8 @@ fun HomeScreen(
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
// Adopt files added outside the app (own file sync, USB…) into the library. // Adopt files added outside the app (own file sync, USB…) into the library.
runCatching { app.recordingRepository.refreshRoot() } runCatching { app.recordingRepository.refreshRoot() }
storageOk = runCatching { app.recordingRepository.libraryAccessible() }
.getOrDefault(true)
} }
Scaffold( Scaffold(
@ -124,6 +145,19 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
StorageChip() StorageChip()
if (storageOk == false) {
val ctx = context
StorageAccessCard(onGrant = {
runCatching {
ctx.startActivity(
android.content.Intent(
android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
android.net.Uri.parse("package:${ctx.packageName}"),
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
})
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
QuickCard("Settings", "Appearance, custom settings", QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings) Icons.Filled.Settings, onOpenSettings)
@ -285,6 +319,23 @@ private fun formatBytes(bytes: Long): String = when {
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
} }
/** Shown when the OS file grant is missing: library stays empty until then. */
@Composable
private fun StorageAccessCard(onGrant: () -> Unit) {
Card(colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("File access is off — your recordings can't be listed.",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onErrorContainer)
Text("SHONAR keeps its library in your Music folder. Allow all-files access, then come back.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer)
TextButton(onClick = onGrant) { Text("Open system settings") }
}
}
}
/** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */ /** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */
@Composable @Composable
private fun StorageChip() { private fun StorageChip() {

View file

@ -1,16 +1,17 @@
package com.shonar.ui.home package com.shonar.ui.home
import android.media.MediaPlayer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.shonar.ShonarApplication import com.shonar.ShonarApplication
import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity import com.shonar.recording.RecordingEntity
import com.shonar.recording.RecordingService import com.shonar.recording.RecordingService
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -18,12 +19,14 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
val recordings: StateFlow<List<RecordingEntity>> = app.recordingRepository.recordings val recordings: StateFlow<List<RecordingEntity>> = app.recordingRepository.recordings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val recorderState = RecordingService.snapshot val recorderState = RecordingService.snapshot
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null) /** Shared player state; null = nothing loaded. Row shows Pause only while playing. */
val playingId: StateFlow<String?> = _playingId val playback: StateFlow<PlaybackController.State?> = app.playback.state
val playingId: StateFlow<String?> = app.playback.state
.map { s -> s?.takeIf { it.playing }?.id }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _notice = kotlinx.coroutines.flow.MutableStateFlow<String?>(null) private val _notice = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
/** Transient rename/delete failures surface here; cleared on next action. */ /** Transient rename/delete failures surface here; cleared on next action. */
val notice: StateFlow<String?> = _notice val notice: StateFlow<String?> = _notice
private var player: MediaPlayer? = null
fun start() = RecordingService.command(app, RecordingService.ACTION_START) fun start() = RecordingService.command(app, RecordingService.ACTION_START)
fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE) fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE)
@ -34,24 +37,12 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
fun discard() = RecordingService.command(app, RecordingService.ACTION_DELETE) fun discard() = RecordingService.command(app, RecordingService.ACTION_DELETE)
fun togglePlayback(recording: RecordingEntity) { fun togglePlayback(recording: RecordingEntity) {
if (_playingId.value == recording.id) { app.playback.toggle(recording.id, recording.title, recording.filePath)
player?.let { if (it.isPlaying) it.pause() else it.start() }
return
}
player?.release()
player = runCatching {
MediaPlayer().apply {
setDataSource(recording.filePath)
setOnPreparedListener { it.start(); _playingId.value = recording.id }
setOnCompletionListener { _playingId.value = null; it.release(); player = null }
prepareAsync()
}
}.getOrNull()
} }
fun delete(recording: RecordingEntity) { fun delete(recording: RecordingEntity) {
_notice.value = null _notice.value = null
if (_playingId.value == recording.id) stopPlayback() if (app.playback.state.value?.id == recording.id) app.playback.stop()
viewModelScope.launch { viewModelScope.launch {
runCatching { app.recordingRepository.delete(recording) } runCatching { app.recordingRepository.delete(recording) }
.onFailure { _notice.value = it.message ?: "Delete failed." } .onFailure { _notice.value = it.message ?: "Delete failed." }
@ -66,17 +57,6 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
} }
} }
private fun stopPlayback() {
player?.release()
player = null
_playingId.value = null
}
override fun onCleared() {
stopPlayback()
super.onCleared()
}
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {