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:foregroundServiceType="microphone" />
<receiver
android:name=".recording.PlaybackStopReceiver"
android:exported="false" />
<receiver
android:name=".widget.ShonarRecordWidget"
android:exported="false"

View file

@ -2,6 +2,8 @@ package com.shonar
import android.app.Application
import androidx.room.Room
import com.shonar.recording.PlaybackController
import com.shonar.recording.PlaybackOwner
import com.shonar.recording.RecordingRepository
import com.shonar.recording.ShonarDatabase
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
* tooling outside this app.
*/
class ShonarApplication : Application() {
class ShonarApplication : Application(), PlaybackOwner {
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 {
Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db")
.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()
}
/** 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
* the user's own file sync, USB, …). Flat scan, top level only;

View file

@ -1,14 +1,11 @@
package com.shonar.ui.detail
import android.media.MediaPlayer
import android.media.PlaybackParams
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication
import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -46,11 +43,8 @@ class DetailsViewModel(
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
private var player: MediaPlayer? = null
private var pollJob: Job? = null
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 =
object : ViewModelProvider.Factory {
@ -70,6 +64,26 @@ class DetailsViewModel(
init {
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() {
@ -115,120 +129,20 @@ class DetailsViewModel(
_state.value = UiState.ReadyState(cur.copy(saveError = null))
}
// ---- playback ------------------------------------------------------------
// ---- playback (shared controller; this screen only mirrors it) ----
fun togglePlayback() {
val cur = current() ?: return
val p = player
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
}
}
app.playback.toggle(cur.recording.id, cur.recording.title, cur.recording.filePath)
}
fun seekTo(positionMs: Long) {
val cur = current() ?: return
val duration = cur.durationMs.coerceAtLeast(1)
val clamped = positionMs.coerceIn(0, duration)
player?.seekTo(clamped.toInt())
_state.value = UiState.ReadyState(cur.copy(positionMs = clamped))
app.playback.seekTo(positionMs)
}
fun cycleSpeed() {
val cur = current() ?: return
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
app.playback.cycleSpeed()
}
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 scope = rememberCoroutineScope()
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(
ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) vm.start() }
@ -87,6 +106,8 @@ fun HomeScreen(
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
// Adopt files added outside the app (own file sync, USB…) into the library.
runCatching { app.recordingRepository.refreshRoot() }
storageOk = runCatching { app.recordingRepository.libraryAccessible() }
.getOrDefault(true)
}
Scaffold(
@ -124,6 +145,19 @@ fun HomeScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
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))
QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings)
@ -285,6 +319,23 @@ private fun formatBytes(bytes: Long): String = when {
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. */
@Composable
private fun StorageChip() {

View file

@ -1,16 +1,17 @@
package com.shonar.ui.home
import android.media.MediaPlayer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.shonar.ShonarApplication
import com.shonar.recording.PlaybackController
import com.shonar.recording.RecordingEntity
import com.shonar.recording.RecordingService
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@ -18,12 +19,14 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
val recordings: StateFlow<List<RecordingEntity>> = app.recordingRepository.recordings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val recorderState = RecordingService.snapshot
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val playingId: StateFlow<String?> = _playingId
/** Shared player state; null = nothing loaded. Row shows Pause only while playing. */
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)
/** Transient rename/delete failures surface here; cleared on next action. */
val notice: StateFlow<String?> = _notice
private var player: MediaPlayer? = null
fun start() = RecordingService.command(app, RecordingService.ACTION_START)
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 togglePlayback(recording: RecordingEntity) {
if (_playingId.value == recording.id) {
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()
app.playback.toggle(recording.id, recording.title, recording.filePath)
}
fun delete(recording: RecordingEntity) {
_notice.value = null
if (_playingId.value == recording.id) stopPlayback()
if (app.playback.state.value?.id == recording.id) app.playback.stop()
viewModelScope.launch {
runCatching { app.recordingRepository.delete(recording) }
.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 {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {