diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4b65913..40bb1b3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,9 @@ android:exported="false" android:foregroundServiceType="microphone" /> + (null) + val state: StateFlow = _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 +} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt index be09d28..9928c60 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt @@ -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; diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt index a550d28..352a4c4 100644 --- a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt @@ -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.Loading) val state: StateFlow = _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() - } } diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 631336f..a1a3f5b 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -68,6 +68,25 @@ fun HomeScreen( val notice by vm.notice.collectAsState() val scope = rememberCoroutineScope() var consentSeen by remember { mutableStateOf(null) } + var storageOk by remember { mutableStateOf(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() { diff --git a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt index 8379d2e..a66ab81 100644 --- a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt @@ -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> = app.recordingRepository.recordings .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) val recorderState = RecordingService.snapshot - private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null) - val playingId: StateFlow = _playingId + /** Shared player state; null = nothing loaded. Row shows Pause only while playing. */ + val playback: StateFlow = app.playback.state + val playingId: StateFlow = app.playback.state + .map { s -> s?.takeIf { it.playing }?.id } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) private val _notice = kotlinx.coroutines.flow.MutableStateFlow(null) /** Transient rename/delete failures surface here; cleared on next action. */ val notice: StateFlow = _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 {