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:
parent
a445105d86
commit
d68aab5336
7 changed files with 432 additions and 142 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue