diff options
Diffstat (limited to 'app')
16 files changed, 937 insertions, 24 deletions
diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt index 063e824..23032fd 100644 --- a/app/src/main/java/org/terst/listen/ListenApp.kt +++ b/app/src/main/java/org/terst/listen/ListenApp.kt @@ -1,5 +1,13 @@ package org.terst.listen import android.app.Application +import org.terst.listen.work.Scheduling -class ListenApp : Application() +class ListenApp : Application() { + override fun onCreate() { + super.onCreate() + // Scheduled on every launch but enqueued with KEEP, so an existing schedule is left + // alone rather than having its interval reset each time the app opens. + Scheduling.schedule(this) + } +} diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt index fbdcba2..26db0a3 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt @@ -111,6 +111,12 @@ fun EpisodeEntity.toMediaItem(): MediaItem = MediaItem.Builder() .setMediaId(id) .setUri(localPath ?: mediaUrl) .setMimeType(mediaType) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setIsPlayable(true) + .build() + ) .build() /** Applies a show's speed, falling back to the global default. */ diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt new file mode 100644 index 0000000..af9cc14 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt @@ -0,0 +1,128 @@ +package org.terst.listen.playback + +import android.content.ComponentName +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** What the UI needs to know about playback. */ +data class NowPlaying( + val episodeId: String?, + val title: String?, + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long?, + val speed: Float, +) + +/** + * Binds the UI to the playback service. + * + * Media3's MediaController is the supported way for an app process to drive a + * MediaSessionService, rather than reaching for the ExoPlayer instance directly -- the service + * may be restarted independently of the activity, and a direct reference would go stale without + * any obvious symptom. + */ +class PlayerConnection(private val context: Context) { + + private var controller: MediaController? = null + + private val _nowPlaying = MutableStateFlow( + NowPlaying(null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f) + ) + val nowPlaying: StateFlow<NowPlaying> = _nowPlaying.asStateFlow() + + fun connect() { + if (controller != null) return + val token = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + val future = MediaController.Builder(context, token).buildAsync() + future.addListener( + { + controller = runCatching { future.get() }.getOrNull() + controller?.addListener(listener) + publish() + }, + MoreExecutors.directExecutor(), + ) + } + + fun release() { + controller?.removeListener(listener) + controller?.release() + controller = null + } + + private val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) = publish() + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = publish() + override fun onPlaybackStateChanged(playbackState: Int) = publish() + } + + /** + * Starts an episode, seeking to where the listener got to. + * + * The resume decision belongs to PlaybackProgress, not here: a stored position past the end + * has to restart rather than seek past the end and stall. + */ + fun play( + item: MediaItem, + storedPositionMs: Long, + durationMs: Long?, + speed: Float, + progress: PlaybackProgress = PlaybackProgress(), + ) { + val player = controller ?: return + player.setMediaItem(item) + player.prepare() + player.seekTo(progress.resumePosition(storedPositionMs, durationMs)) + player.applySpeed(speed, speed) + player.play() + publish() + } + + fun togglePlayPause() { + val player = controller ?: return + if (player.isPlaying) player.pause() else player.play() + publish() + } + + fun seekBy(deltaMs: Long) { + val player = controller ?: return + player.seekTo((player.currentPosition + deltaMs).coerceAtLeast(0)) + publish() + } + + fun pause() { + controller?.pause() + publish() + } + + /** Used by the sleep timer's fade. */ + fun setVolume(volume: Float) { + controller?.volume = volume.coerceIn(0f, 1f) + } + + fun setSpeed(speed: Float) { + controller?.applySpeed(speed, speed) + publish() + } + + /** Called on a UI tick, since position changes without any callback firing. */ + fun publish() { + val player = controller + _nowPlaying.value = NowPlaying( + episodeId = player?.currentMediaItem?.mediaId, + title = player?.currentMediaItem?.mediaMetadata?.title?.toString(), + isPlaying = player?.isPlaying == true, + positionMs = player?.currentPosition ?: 0, + durationMs = player?.duration?.takeIf { it > 0 }, + speed = player?.playbackParameters?.speed ?: 1.0f, + ) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt new file mode 100644 index 0000000..e19834e --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt @@ -0,0 +1,54 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.SubscriptionEntity +import java.util.concurrent.TimeUnit + +/** The row's five actions, bundled so the signature stays readable. */ +data class EpisodeActions( + val play: () -> Unit, + val enqueue: () -> Unit, + val playNext: () -> Unit, + val download: () -> Unit, + val togglePlayed: () -> Unit, +) + diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt index 4a5fec2..439f4cb 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt @@ -44,25 +44,20 @@ import org.terst.listen.data.SubscriptionEntity import java.util.concurrent.TimeUnit @Composable -internal fun EpisodeRow( - episode: EpisodeEntity, - onEnqueue: () -> Unit, - onPlayNext: () -> Unit, - onDownload: () -> Unit, - onTogglePlayed: () -> Unit, -) { +internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions) { Column(Modifier.fillMaxWidth().padding(16.dp)) { Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2) Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onPlayNext) { Text("Next") } - Button(onClick = onEnqueue) { Text("Queue") } + Button(onClick = actions.play) { Text(if (episode.positionMs > 0) "Resume" else "Play") } + Button(onClick = actions.playNext) { Text("Next") } + Button(onClick = actions.enqueue) { Text("Queue") } if (episode.downloadState != DownloadState.DOWNLOADED) { - Button(onClick = onDownload, enabled = episode.downloadState != DownloadState.DOWNLOADING) { + Button(onClick = actions.download, enabled = episode.downloadState != DownloadState.DOWNLOADING) { Text(if (episode.downloadState == DownloadState.DOWNLOADING) "…" else "Get") } } - Button(onClick = onTogglePlayed) { + Button(onClick = actions.togglePlayed) { Text(if (episode.playedAtMs == null) "Played" else "Unplayed") } } diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt index ef1cca8..80631c9 100644 --- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -47,13 +48,16 @@ import java.util.concurrent.TimeUnit @Composable fun LibraryScreen( vm: ListenViewModel, + player: PlayerViewModel, onOpenSubscription: (String) -> Unit, onOpenQueue: () -> Unit, + onOpenSettings: () -> Unit, ) { val subscriptions by vm.subscriptions.collectAsState() val queue by vm.queue.collectAsState() val refreshing by vm.refreshing.collectAsState() val banner by vm.banner.collectAsState() + val nowPlaying by player.nowPlaying.collectAsState() val snackbar = remember { SnackbarHostState() } var adding by remember { mutableStateOf(false) } @@ -75,10 +79,22 @@ fun LibraryScreen( IconButton(onClick = vm::refreshAll, enabled = !refreshing) { Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds") } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } }, ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, + bottomBar = { + NowPlayingBar( + state = nowPlaying, + onTogglePlayPause = player::togglePlayPause, + onSkipBack = player::skipBack, + onSkipForward = player::skipForward, + onOpenQueue = onOpenQueue, + ) + }, ) { padding -> Column(Modifier.padding(padding).fillMaxSize()) { if (refreshing) { diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt index fa8a89d..c7a27e5 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -15,6 +15,9 @@ import org.terst.listen.data.RefreshOutcome import org.terst.listen.data.SubscriptionEntity import org.terst.listen.data.SubscriptionKind import org.terst.listen.feed.SubscriptionResolver +import android.net.Uri +import org.terst.listen.opml.Opml +import org.terst.listen.opml.OpmlFeed /** Transient message for the UI. Errors are surfaced, never only logged. */ data class Banner(val text: String, val isError: Boolean = false) @@ -37,6 +40,61 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { private val _refreshing = MutableStateFlow(false) val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow() + // --- OPML ---------------------------------------------------------------------------- + + /** + * Imports subscriptions from an OPML file. + * + * Reports how many were added and how many failed. Silently importing 40 of 60 feeds is the + * kind of partial success that only surfaces weeks later when a show never appears. + */ + fun importOpml(uri: Uri) { + viewModelScope.launch { + val feeds = runCatching { + getApplication<Application>().contentResolver.openInputStream(uri) + ?.use { Opml.parse(it) } + .orEmpty() + }.getOrElse { + _banner.value = Banner("Could not read that file: ${it.message}", isError = true) + return@launch + } + + if (feeds.isEmpty()) { + _banner.value = Banner("No subscriptions found in that file", isError = true) + return@launch + } + + var added = 0 + var failed = 0 + for (feed in feeds) { + graph.repository.subscribe(feed.xmlUrl).fold( + onSuccess = { added++ }, + onFailure = { failed++ }, + ) + } + _banner.value = if (failed > 0) { + Banner("Imported $added, $failed failed", isError = true) + } else { + Banner("Imported $added subscription(s)") + } + } + } + + fun exportOpml(uri: Uri) { + viewModelScope.launch { + val feeds = graph.subscriptions.getAll().map { + OpmlFeed(title = it.title, xmlUrl = it.feedUrl, htmlUrl = it.link) + } + runCatching { + getApplication<Application>().contentResolver.openOutputStream(uri) + ?.use { Opml.write(feeds, it) } + }.fold( + onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") }, + onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) }, + ) + } + } + fun dismissBanner() { _banner.value = null } diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt index 6fd76e5..a395b0f 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -16,10 +16,11 @@ import androidx.navigation.compose.rememberNavController class MainActivity : ComponentActivity() { private val viewModel: ListenViewModel by viewModels() + private val playerViewModel: PlayerViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { ListenTheme { ListenApp(viewModel) } } + setContent { ListenTheme { ListenApp(viewModel, playerViewModel) } } } } @@ -29,26 +30,34 @@ fun ListenTheme(content: @Composable () -> Unit) { } @Composable -private fun ListenApp(viewModel: ListenViewModel) { +private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) { val nav = rememberNavController() NavHost(navController = nav, startDestination = "library") { composable("library") { LibraryScreen( vm = viewModel, + player = player, onOpenSubscription = { nav.navigate("subscription/$it") }, onOpenQueue = { nav.navigate("queue") }, + onOpenSettings = { nav.navigate("settings") }, ) } + composable("settings") { + SettingsScreen(vm = viewModel, + player = player, onBack = { nav.popBackStack() }) + } composable("queue") { - QueueScreen(vm = viewModel, onBack = { nav.popBackStack() }) + QueueScreen(vm = viewModel, + player = player, onBack = { nav.popBackStack() }) } composable("subscription/{id}") { entry -> val id = entry.arguments?.getString("id") if (id == null) { nav.popBackStack() } else { - SubscriptionScreen(vm = viewModel, subscriptionId = id, onBack = { nav.popBackStack() }) + SubscriptionScreen(vm = viewModel, + player = player, subscriptionId = id, onBack = { nav.popBackStack() }) } } } diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt new file mode 100644 index 0000000..54718a4 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt @@ -0,0 +1,101 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.terst.listen.playback.NowPlaying + +/** + * The persistent transport bar. + * + * Hidden entirely when nothing is loaded, rather than shown in a disabled state -- a dead control + * strip at the bottom of every screen is worse than no strip. + */ +@Composable +fun NowPlayingBar( + state: NowPlaying, + onTogglePlayPause: () -> Unit, + onSkipBack: () -> Unit, + onSkipForward: () -> Unit, + onOpenQueue: () -> Unit, +) { + if (state.episodeId == null) return + + Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) { + Column { + val fraction = state.durationMs + ?.takeIf { it > 0 } + ?.let { (state.positionMs.toFloat() / it).coerceIn(0f, 1f) } + if (fraction != null) { + LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth()) + } + + Row( + Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Column(Modifier.weight(1f).clickable(onClick = onOpenQueue).padding(8.dp)) { + Text( + state.title ?: "Playing", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${formatClock(state.positionMs)} · ${state.speed}×", + style = MaterialTheme.typography.bodySmall, + ) + } + + IconButton(onClick = onSkipBack) { + Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds") + } + IconButton(onClick = onTogglePlayPause) { + Icon( + if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + ) + } + IconButton(onClick = onSkipForward) { + Icon(Icons.Default.FastForward, contentDescription = "Forward 30 seconds") + } + } + } + } +} + +private const val SECONDS_PER_MINUTE = 60 +private const val MINUTES_PER_HOUR = 60 +private const val MS_PER_SECOND = 1000 + +internal fun formatClock(ms: Long): String { + val totalSeconds = ms / MS_PER_SECOND + val seconds = totalSeconds % SECONDS_PER_MINUTE + val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR + val hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR) + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt new file mode 100644 index 0000000..350d3a0 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -0,0 +1,123 @@ +package org.terst.listen.ui + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.Graph +import org.terst.listen.playback.NowPlaying +import org.terst.listen.playback.PlayerConnection +import org.terst.listen.playback.SleepTimer +import org.terst.listen.playback.toMediaItem + +/** + * Playback, speed and the sleep timer. + * + * Separate from ListenViewModel because these are the controls that operate on whatever is + * currently playing, regardless of which screen is open -- and because one view model covering + * both grew past the point where its function list said anything useful. + */ +class PlayerViewModel(app: Application) : AndroidViewModel(app) { + + private val graph = Graph.get(app) + private val _banner = MutableStateFlow<Banner?>(null) + val banner: StateFlow<Banner?> = _banner.asStateFlow() + + fun dismissBanner() { + _banner.value = null + } + + private val connection = PlayerConnection(app).also { it.connect() } + val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying + + init { + // Position advances with no callback of its own, so the bar is driven by a tick. + viewModelScope.launch { + while (true) { + delay(PROGRESS_TICK_MS) + connection.publish() + } + } + } + + override fun onCleared() { + connection.release() + super.onCleared() + } + + /** Starts an episode, resuming where the listener left off and at the show's speed. */ + fun play(episode: EpisodeEntity) { + viewModelScope.launch { + val speed = graph.subscriptions.getById(episode.subscriptionId)?.playbackSpeed ?: DEFAULT_SPEED + connection.play( + item = episode.toMediaItem(), + storedPositionMs = episode.positionMs, + durationMs = episode.durationMs, + speed = speed, + ) + } + } + + fun togglePlayPause() = connection.togglePlayPause() + + fun skipBack() = connection.seekBy(-SKIP_BACK_MS) + + fun skipForward() = connection.seekBy(SKIP_FORWARD_MS) + + fun setSpeed(speed: Float) = connection.setSpeed(speed) + + // --- sleep timer --------------------------------------------------------------------- + + private val sleepTimer = SleepTimer() + private val _sleepRemainingMs = MutableStateFlow<Long?>(null) + val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow() + + fun armSleepTimer(minutes: Long) { + sleepTimer.armFor(minutes * MS_PER_MINUTE, System.currentTimeMillis()) + watchSleepTimer() + } + + fun cancelSleepTimer() { + sleepTimer.cancel() + _sleepRemainingMs.value = null + } + + /** + * Polls the timer rather than scheduling a single alarm: the fade needs a volume update + * every tick anyway, and a deadline read from the clock survives the process being paused. + */ + private fun watchSleepTimer() { + viewModelScope.launch { + while (sleepTimer.isArmed) { + val now = System.currentTimeMillis() + _sleepRemainingMs.value = sleepTimer.remainingMs(now) + if (sleepTimer.hasExpired(now)) { + connection.setVolume(0f) + connection.pause() + sleepTimer.cancel() + _sleepRemainingMs.value = null + _banner.value = Banner("Sleep timer finished") + break + } + connection.setVolume(sleepTimer.volumeAt(now)) + delay(PROGRESS_TICK_MS) + } + } + } + + + private companion object { + const val PROGRESS_TICK_MS = 1_000L + const val DEFAULT_SPEED = 1.0f + // Asymmetric on purpose: back is for catching a missed sentence, forward for skipping + // an ad break. + const val SKIP_BACK_MS = 15_000L + const val SKIP_FORWARD_MS = 30_000L + const val MS_PER_MINUTE = 60_000L + } +} diff --git a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt index e297489..fbeb7d8 100644 --- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt @@ -45,7 +45,7 @@ import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3Api::class) @Composable -fun QueueScreen(vm: ListenViewModel, onBack: () -> Unit) { +fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) { val queue by vm.queue.collectAsState() Scaffold( topBar = { @@ -65,6 +65,7 @@ fun QueueScreen(vm: ListenViewModel, onBack: () -> Unit) { Text(episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) } + Button(onClick = { player.play(episode) }) { Text("Play") } Button(onClick = { vm.removeFromQueue(episode.id) }) { Text("Remove") } } HorizontalDivider() diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt new file mode 100644 index 0000000..bdb1cef --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt @@ -0,0 +1,133 @@ +package org.terst.listen.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** Offered speeds. Beyond 2x speech becomes hard to follow, so the list stops there. */ +private val SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f) + +/** Sleep durations in minutes. */ +private val SLEEP_MINUTES = listOf(15L, 30L, 45L, 60L) + +private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xml", "*/*") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) { + val nowPlaying by player.nowPlaying.collectAsState() + val sleepRemaining by player.sleepRemainingMs.collectAsState() + + // The system file picker rather than a permission-hungry file browser: it grants access to + // exactly the one file the user chose and nothing else. + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> uri?.let(vm::importOpml) } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/x-opml") + ) { uri -> uri?.let(vm::exportOpml) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SpeedSection(current = nowPlaying.speed, onSelect = player::setSpeed) + HorizontalDivider() + SleepSection(remainingMs = sleepRemaining, onArm = player::armSleepTimer, onCancel = player::cancelSleepTimer) + HorizontalDivider() + SubscriptionsSection( + onImport = { importLauncher.launch(OPML_MIME_TYPES) }, + onExport = { exportLauncher.launch("listen-subscriptions.opml") }, + ) + } + } +} + +@Composable +private fun SpeedSection(current: Float, onSelect: (Float) -> Unit) { + Section("Playback speed") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SPEEDS.forEach { speed -> + if (current == speed) { + Button(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") } + } else { + OutlinedButton(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") } + } + } + } + } +} + +@Composable +private fun SleepSection(remainingMs: Long?, onArm: (Long) -> Unit, onCancel: () -> Unit) { + Section("Sleep timer") { + if (remainingMs != null) { + Text("Stopping in ${formatClock(remainingMs)}") + Button(onClick = onCancel) { Text("Cancel") } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SLEEP_MINUTES.forEach { minutes -> + OutlinedButton(onClick = { onArm(minutes) }) { Text("${minutes}m") } + } + } + } + Text( + "Volume fades over the last 20 seconds rather than cutting.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun SubscriptionsSection(onImport: () -> Unit, onExport: () -> Unit) { + Section("Subscriptions") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Some exporters use text/xml and some a bare octet-stream, so the filter is broad; + // narrowing it hides the file the user is trying to pick. + OutlinedButton(onClick = onImport) { Text("Import OPML") } + OutlinedButton(onClick = onExport) { Text("Export OPML") } + } + Text( + "OPML carries subscriptions only. Playback positions, history and the queue do not " + + "transfer from another app.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun Section(title: String, content: @Composable () -> Unit) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } +} diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt index 92568de..483333d 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -45,7 +45,12 @@ import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SubscriptionScreen(vm: ListenViewModel, subscriptionId: String, onBack: () -> Unit) { +fun SubscriptionScreen( + vm: ListenViewModel, + player: PlayerViewModel, + subscriptionId: String, + onBack: () -> Unit, +) { val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList()) val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId } @@ -61,12 +66,15 @@ fun SubscriptionScreen(vm: ListenViewModel, subscriptionId: String, onBack: () - items(episodes, key = { it.id }) { episode -> EpisodeRow( episode = episode, - onEnqueue = { vm.enqueue(episode.id) }, - onPlayNext = { vm.playNext(episode.id) }, - onDownload = { vm.download(episode) }, - onTogglePlayed = { - if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id) - }, + actions = EpisodeActions( + play = { player.play(episode) }, + enqueue = { vm.enqueue(episode.id) }, + playNext = { vm.playNext(episode.id) }, + download = { vm.download(episode) }, + togglePlayed = { + if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id) + }, + ), ) HorizontalDivider() } diff --git a/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt b/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt new file mode 100644 index 0000000..cd329b3 --- /dev/null +++ b/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt @@ -0,0 +1,45 @@ +package org.terst.listen.work + +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeDao +import org.terst.listen.queue.RetentionPolicy +import java.io.File + +/** + * Reclaims played downloads. + * + * Extracted from the Worker so it is testable: this deletes a listener's audio, and the failure + * mode is silent and permanent. + */ +class RetentionSweeper( + private val episodes: EpisodeDao, + private val policy: RetentionPolicy, + /** Injected so the undeletable-file path can be exercised without a read-only filesystem. */ + private val deleteFile: (File) -> Boolean = { it.delete() }, +) { + + /** Returns how many episodes were reclaimed. */ + suspend fun sweep(nowMs: Long): Int { + // The query is a coarse filter; the policy makes the decision, including cases SQL + // cannot express such as an episode resumed after being marked played. + val candidates = episodes.playedDownloadsBefore(nowMs) + var reclaimed = 0 + + for (episode in policy.selectDeletable(candidates, nowMs)) { + val path = episode.localPath ?: continue + val file = File(path) + + // A row pointing at a file that is already gone still needs clearing, or it stays a + // permanently unplayable DOWNLOADED entry. + val gone = !file.exists() || deleteFile(file) + + // Only clear the row once the file is actually gone. The other order orphans the + // file: nothing else knows about it, so the disk is never reclaimed. + if (gone) { + episodes.updateDownload(episode.id, DownloadState.NONE, null, null) + reclaimed++ + } + } + return reclaimed + } +} diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt new file mode 100644 index 0000000..75c6625 --- /dev/null +++ b/app/src/main/java/org/terst/listen/work/Workers.kt @@ -0,0 +1,84 @@ +package org.terst.listen.work + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.terst.listen.data.Graph +import org.terst.listen.data.RefreshOutcome +import java.util.concurrent.TimeUnit + +/** + * Refreshes every subscription. + * + * Never returns failure. A retry would re-fetch every feed because one publisher's server was + * down, and the reason is already recorded against the subscription and shown in the library -- + * which is the point of storing it rather than logging it. + */ +class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val outcomes = Graph.get(applicationContext).repository.refreshAll() + val failed = outcomes.values.count { it is RefreshOutcome.Failed } + return Result.success( + androidx.work.Data.Builder() + .putInt("refreshed", outcomes.size - failed) + .putInt("failed", failed) + .build() + ) + } +} + +/** Reclaims played downloads; the decisions live in RetentionSweeper, which is tested. */ +class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val graph = Graph.get(applicationContext) + val reclaimed = RetentionSweeper(graph.episodes, graph.retention) + .sweep(System.currentTimeMillis()) + return Result.success(androidx.work.Data.Builder().putInt("reclaimed", reclaimed).build()) + } +} + +object Scheduling { + + private const val REFRESH_WORK = "listen-refresh" + private const val RETENTION_WORK = "listen-retention" + private const val REFRESH_HOURS = 6L + private const val RETENTION_HOURS = 12L + + /** + * Both jobs are KEEP rather than UPDATE, so the schedule survives an app restart instead of + * resetting its interval every launch -- which on a frequently-opened app means the periodic + * work never actually fires. + */ + fun schedule(context: Context) { + val manager = WorkManager.getInstance(context) + + manager.enqueueUniquePeriodicWork( + REFRESH_WORK, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder<RefreshWorker>(REFRESH_HOURS, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + // Unmetered: refreshing every feed is cheap, but downloads that follow + // are not, and a phone on mobile data should not discover that by itself. + .setRequiredNetworkType(NetworkType.UNMETERED) + .build() + ) + .build(), + ) + + manager.enqueueUniquePeriodicWork( + RETENTION_WORK, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder<RetentionWorker>(RETENTION_HOURS, TimeUnit.HOURS) + .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) + .build(), + ) + } +} diff --git a/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt b/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt new file mode 100644 index 0000000..65b739f --- /dev/null +++ b/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt @@ -0,0 +1,144 @@ +package org.terst.listen.work + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.FakeEpisodeDao +import org.terst.listen.queue.RetentionPolicy +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * This code deletes a listener's audio, so it is worth testing directly rather than leaving + * inside a Worker where only an emulator could reach it. + */ +class RetentionSweeperTest { + + @get:Rule val temp = TemporaryFolder() + + private val day = TimeUnit.DAYS.toMillis(1) + private lateinit var dao: FakeEpisodeDao + private lateinit var sweeper: RetentionSweeper + + @Before + fun setUp() { + dao = FakeEpisodeDao() + sweeper = RetentionSweeper(dao, RetentionPolicy(gracePeriodMs = day)) + } + + private fun downloaded( + id: String, + playedAtMs: Long?, + positionMs: Long = 0, + file: File? = temp.newFile("$id.mp3").apply { writeText("audio") }, + ): EpisodeEntity { + val episode = EpisodeEntity( + id = id, + subscriptionId = "s1", + guid = id, + title = "Episode $id", + mediaUrl = "https://cdn/$id.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + playedAtMs = playedAtMs, + positionMs = positionMs, + downloadState = DownloadState.DOWNLOADED, + localPath = file?.absolutePath, + ) + dao.rows += episode + return episode + } + + @Test + fun `reclaims a played download past its grace period`() = runTest { + val episode = downloaded("a", playedAtMs = 0) + val reclaimed = sweeper.sweep(nowMs = 2 * day) + + assertEquals(1, reclaimed) + assertTrue("the file should be gone", !File(episode.localPath!!).exists()) + val row = dao.rows.single() + assertEquals(DownloadState.NONE, row.downloadState) + assertNull(row.localPath) + } + + @Test + fun `leaves an unplayed download alone`() = runTest { + val episode = downloaded("a", playedAtMs = null) + assertEquals(0, sweeper.sweep(nowMs = 100 * day)) + assertTrue(File(episode.localPath!!).exists()) + assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState) + } + + @Test + fun `leaves a download inside its grace period alone`() = runTest { + val episode = downloaded("a", playedAtMs = 10 * day) + assertEquals(0, sweeper.sweep(nowMs = 10 * day + 1000)) + assertTrue(File(episode.localPath!!).exists()) + } + + /** Marked played but resumed since: the audio is in use. */ + @Test + fun `leaves a resumed episode alone`() = runTest { + val episode = downloaded("a", playedAtMs = 0, positionMs = 500_000) + assertEquals(0, sweeper.sweep(nowMs = 100 * day)) + assertTrue(File(episode.localPath!!).exists()) + } + + /** + * A row pointing at a file already gone should still be cleared, or it stays a + * permanently-unplayable DOWNLOADED entry. + */ + @Test + fun `clears the row when the file has already been removed`() = runTest { + val episode = downloaded("a", playedAtMs = 0) + File(episode.localPath!!).delete() + + assertEquals(1, sweeper.sweep(nowMs = 2 * day)) + assertEquals(DownloadState.NONE, dao.rows.single().downloadState) + assertNull(dao.rows.single().localPath) + } + + /** + * If the file cannot be deleted the row must stay as it is. Clearing it would orphan the + * file: nothing else knows about it, so the disk would never be reclaimed. + */ + @Test + fun `an undeletable file leaves the row untouched`() = runTest { + downloaded("a", playedAtMs = 0) + val stubborn = RetentionSweeper(dao, RetentionPolicy(gracePeriodMs = day), deleteFile = { false }) + + assertEquals(0, stubborn.sweep(nowMs = 2 * day)) + assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState) + assertTrue(dao.rows.single().localPath != null) + } + + @Test + fun `sweeps several episodes and reports the count`() = runTest { + downloaded("a", playedAtMs = 0) + downloaded("b", playedAtMs = 0) + downloaded("c", playedAtMs = null) + + assertEquals(2, sweeper.sweep(nowMs = 2 * day)) + assertEquals(1, dao.rows.count { it.downloadState == DownloadState.DOWNLOADED }) + } + + @Test + fun `an empty library is harmless`() = runTest { + assertEquals(0, sweeper.sweep(nowMs = 100 * day)) + } + + @Test + fun `disabled retention reclaims nothing`() = runTest { + val episode = downloaded("a", playedAtMs = 0) + val off = RetentionSweeper(dao, RetentionPolicy.Disabled) + assertEquals(0, off.sweep(nowMs = 100 * day)) + assertTrue(File(episode.localPath!!).exists()) + } +} |
