diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-19 09:31:21 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-19 09:31:21 +0000 |
| commit | 2329c92fb989b12224a9d1b5fb22e7e0a51f1b45 (patch) | |
| tree | 4cbbdca4458f448b88c0ab9ddcaf4ef73739171f /app/src | |
| parent | 10abc31e8220b39231cac31ee96109071c56c950 (diff) | |
Make the episode screen the player and delete the separate player screen
Opening an episode now puts you in its transport at the stored position;
pressing play changes nothing about the layout. One primary control
(Play/Resume/Pause) with back/forward, a speed chip that opens the four
offered speeds (stored 1.5/1.75 still play and are named on the chip),
and everything secondary behind a top-bar overflow: queue, download,
played, watch, mark-older, chapters, and the publisher-copy action
(behind a dialog carrying its warning). Description shows about two
lines, the rest on tap.
The now-playing bar, library bar, queue, share receiver and nav graph
all open the playing episode's screen. PlayerViewModel stays the single
owner of playback state; no playback decision moved into the UI.
PlayerScreen.kt and WatchButton.kt are deleted. Coverage drop, if any,
is from removing covered dead code.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
25 files changed, 953 insertions, 663 deletions
diff --git a/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt b/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt new file mode 100644 index 0000000..2569408 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/DescriptionPreview.kt @@ -0,0 +1,33 @@ +package org.terst.listen.ui + +/** + * How much of a description shows before the listener asks for the rest. + * + * A show with long notes used to push the transport off the screen, so the notes start as about + * two lines. The rule is line count *and* a character budget: stripped HTML is often one long + * paragraph, which two newline-delimited lines would not limit at all. + */ +object DescriptionPreview { + + private const val MAX_LINES = 2 + + /** Roughly two lines of body text on a phone. */ + const val MAX_CHARS = 140 + + private const val ELLIPSIS = "…" + + data class Preview(val text: String, val truncated: Boolean) + + fun collapse(text: String): Preview { + val lines = text.lines().filter { it.isNotBlank() } + val kept = lines.take(MAX_LINES).joinToString("\n") + val cutByLines = lines.size > MAX_LINES + if (kept.length <= MAX_CHARS) { + return if (cutByLines) Preview(kept + ELLIPSIS, true) else Preview(kept, false) + } + val head = kept.take(MAX_CHARS) + // Back to the last space so a word is not sliced, unless there is no space to back to. + val atWord = head.lastIndexOf(' ').takeIf { it > 0 }?.let { head.take(it) } ?: head + return Preview(atWord.trimEnd() + ELLIPSIS, true) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt new file mode 100644 index 0000000..a3d9ea3 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeDescription.kt @@ -0,0 +1,27 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow + +/** About two lines of the notes; a tap shows the rest, and another tap folds it back. */ +@Composable +fun ExpandableDescription(text: String) { + val preview = DescriptionPreview.collapse(text) + var expanded by remember(text) { mutableStateOf(false) } + val showAll = expanded || !preview.truncated + Text( + if (showAll) text else preview.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (showAll) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = if (preview.truncated) Modifier.clickable { expanded = !expanded } else Modifier, + ) +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt index a108e04..06b9ef3 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt @@ -1,59 +1,25 @@ package org.terst.listen.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -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.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import org.terst.listen.data.EpisodeEntity - -/** The detail screen's actions, bundled so the signature stays readable. */ +/** The episode screen's actions, bundled so the signature stays readable. */ data class EpisodeDetailActions( - val play: () -> Unit, val enqueue: () -> Unit, val download: () -> Unit, /** * Takes the publisher's copy of a waiting episode. Deliberately its own action rather than - * a mode of [download], so it can only be reached from the button that states the trade. + * a mode of [download], so it can only be reached through the confirmation that states the trade. */ - val downloadFromPublisher: () -> Unit = {}, + val downloadFromPublisher: () -> Unit, val togglePlayed: () -> Unit, val markOlderPlayed: () -> Unit, /** Play/pause when this episode is the one playing; starts it when it is not. */ - val togglePlayPause: () -> Unit = {}, + val togglePlayPause: () -> Unit, /** * Where the scrubber was dragged to. Seeks the player when this episode is the one playing, * and otherwise moves where Play will start from -- the screen decides which, because only * it knows whether the drag is fighting a live position tick. */ - val seekTo: (Long) -> Unit = {}, + val seekTo: (Long) -> Unit, + /** Skips back or forward by a step, from wherever this episode currently is. */ + val skip: (deltaMs: Long) -> Unit, + val setSpeed: (Float) -> Unit, ) diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt new file mode 100644 index 0000000..dcdc4ab --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt @@ -0,0 +1,45 @@ +package org.terst.listen.ui + +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.download.WaitingForServerText +import org.terst.listen.feed.Chapter + +/** What the overflow menu needs to know about the open episode. */ +data class OverflowContent( + val episode: EpisodeEntity, + val chapters: List<Chapter>, + val watchLink: String?, + val positionMs: Long, +) + +enum class EpisodeMenuAction { QUEUE, DOWNLOAD, PUBLISHER_COPY, TOGGLE_PLAYED, WATCH, MARK_OLDER_PLAYED, CHAPTERS } + +data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String) + +/** + * What the episode screen's overflow menu holds for a given episode. + * + * Everything that used to be a button beside Play lives here, so nothing was deleted, only moved + * one tap away. Entries that do not apply are absent rather than disabled: a chapters entry on a + * show without chapters would advertise a feature that, across the owner's 61 subscriptions, + * exactly one show has. + */ +object EpisodeMenu { + + fun items(episode: EpisodeEntity, hasChapters: Boolean, watchLink: String?): List<EpisodeMenuItem> = buildList { + add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue")) + add(EpisodeMenuItem(EpisodeMenuAction.DOWNLOAD, "Download")) + WaitingForServerText.publisherOffer(episode)?.let { + add(EpisodeMenuItem(EpisodeMenuAction.PUBLISHER_COPY, it.label)) + } + add( + EpisodeMenuItem( + EpisodeMenuAction.TOGGLE_PLAYED, + if (episode.playedAtMs == null) "Played" else "Unplayed", + ), + ) + if (watchLink != null) add(EpisodeMenuItem(EpisodeMenuAction.WATCH, "Watch")) + add(EpisodeMenuItem(EpisodeMenuAction.MARK_OLDER_PLAYED, "Mark this and older as played")) + if (hasChapters) add(EpisodeMenuItem(EpisodeMenuAction.CHAPTERS, "Chapters")) + } +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt new file mode 100644 index 0000000..f832be3 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt @@ -0,0 +1,143 @@ +package org.terst.listen.ui + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +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.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.download.WaitingForServerText + +/** + * The top bar's `⋮`: every secondary action, one tap away. What it lists is [EpisodeMenu]'s call. + * + * Two entries open a dialog rather than acting at once. The publisher's copy states a trade that + * has to be read before it is made, and the chapter list is a list, not an action. + */ +@Composable +fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) { + var open by remember { mutableStateOf(false) } + var dialog by remember { mutableStateOf<EpisodeMenuAction?>(null) } + val context = LocalContext.current + Box { + IconButton(onClick = { open = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More actions") + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + EpisodeMenu.items(content.episode, content.chapters.isNotEmpty(), content.watchLink).forEach { item -> + DropdownMenuItem( + text = { Text(item.label) }, + onClick = { + open = false + when (item.action) { + EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> dialog = item.action + else -> perform(item.action, actions, content.watchLink, context) + } + }, + ) + } + } + } + when (dialog) { + EpisodeMenuAction.CHAPTERS -> ChaptersDialog(content, actions.seekTo) { dialog = null } + EpisodeMenuAction.PUBLISHER_COPY -> + PublisherCopyDialog(content.episode, actions.downloadFromPublisher) { dialog = null } + else -> Unit + } +} + +private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, watchLink: String?, context: Context) { + when (action) { + EpisodeMenuAction.QUEUE -> actions.enqueue() + EpisodeMenuAction.DOWNLOAD -> actions.download() + EpisodeMenuAction.TOGGLE_PLAYED -> actions.togglePlayed() + EpisodeMenuAction.MARK_OLDER_PLAYED -> actions.markOlderPlayed() + // Not restricted to a package: whichever app claims the link handles it, falling back + // to a browser. + EpisodeMenuAction.WATCH -> watchLink?.let { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, it.toUri())) } + } + EpisodeMenuAction.CHAPTERS, EpisodeMenuAction.PUBLISHER_COPY -> Unit + } +} + +/** The way out of a wait and what it costs, together; wording is [WaitingForServerText]'s. */ +@Composable +private fun PublisherCopyDialog(episode: EpisodeEntity, confirm: () -> Unit, dismiss: () -> Unit) { + val offer = WaitingForServerText.publisherOffer(episode) ?: return + AlertDialog( + onDismissRequest = dismiss, + text = { Text(offer.warning) }, + confirmButton = { TextButton(onClick = { dismiss(); confirm() }) { Text(offer.label) } }, + dismissButton = { TextButton(onClick = dismiss) { Text("Cancel") } }, + ) +} + +/** + * The episode's chapters; tapping one seeks there and closes the list. + * + * Kept off the main screen on purpose: across the owner's 61 subscriptions exactly one show + * publishes chapters, so a permanent list would compete with the timeline for space it rarely earns. + */ +@Composable +private fun ChaptersDialog(content: OverflowContent, onSeek: (Long) -> Unit, dismiss: () -> Unit) { + val currentIndex = content.chapters.indexOfLast { it.startMs <= content.positionMs } + AlertDialog( + onDismissRequest = dismiss, + title = { Text("Chapters") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + content.chapters.forEachIndexed { index, chapter -> + Row( + Modifier.fillMaxWidth() + .clickable { onSeek(chapter.startMs); dismiss() } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + formatClock(chapter.startMs), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(end = 12.dp), + ) + Text( + chapter.title ?: "Chapter ${index + 1}", + style = if (index == currentIndex) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + // Marked so a skipped section is explained rather than just vanishing. + if (!chapter.inToc) Text("break", style = MaterialTheme.typography.bodySmall) + } + } + } + }, + confirmButton = { TextButton(onClick = dismiss) { Text("Close") } }, + ) +} diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index 21a9b5b..c77d997 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -1,16 +1,10 @@ package org.terst.listen.ui -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -18,16 +12,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -39,34 +28,30 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity -import org.terst.listen.download.WaitingForServerText import org.terst.listen.data.EpisodeMidrollEntity import org.terst.listen.data.playbackDurationMs +import org.terst.listen.feed.Chapter import org.terst.listen.feed.WatchLink -import org.terst.listen.fingerprint.BreakTrack import org.terst.listen.fingerprint.EpisodeSkips import org.terst.listen.fingerprint.SkipSummary import org.terst.listen.playback.NowPlaying +import org.terst.listen.playback.PlaybackSpeed /** - * One episode in full. + * One episode in full, and the player. * - * The list can only show a couple of lines, so the description — often the only place the show - * says what an episode is actually about — had nowhere to go. This is also where bulk actions - * live: the row has five controls already and a sixth would not fit. + * There is no separate player screen: opening an episode puts you in its transport at the + * position you left, and pressing play changes nothing about the layout. Playback state is read + * from [PlayerViewModel], which already owns it; this screen holds only what describes the open + * episode. Every playback decision -- completion, queue advance, outro, ad skipping -- stays in + * the service, so a button here only ever asks the player to start, pause or seek. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -78,27 +63,47 @@ fun EpisodeScreen( ) { var episode by remember { mutableStateOf<EpisodeEntity?>(null) } var ranges by remember { mutableStateOf<List<EpisodeMidrollEntity>>(emptyList()) } + var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) } + var speed by remember { mutableFloatStateOf(PlaybackSpeed.DEFAULT) } LaunchedEffect(episodeId) { - episode = vm.episode(episodeId) + val loaded = vm.episode(episodeId) + episode = loaded ranges = vm.midrollRanges(episodeId) + if (loaded != null) { + speed = player.speedFor(loaded) + chapters = player.chaptersFor(loaded) + } } val scope = rememberCoroutineScope() val rules by vm.rules.collectAsState() val nowPlaying by player.nowPlaying.collectAsState() val current = episode + val hooks = EpisodeHooks( + onSpeed = { speed = it }, + onEpisode = { episode = it }, + reload = { scope.launch { episode = vm.episode(episodeId) } }, + ) Scaffold( - topBar = { EpisodeTopBar(current?.title, nav.back) }, - bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) }, + topBar = { + EpisodeTopBar(current?.title, nav.back) { + current?.let { shown -> + val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs + EpisodeOverflow( + OverflowContent(shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs), + episodeActions(vm, player, shown, nowPlaying, hooks), + ) + } + } + }, + // The playing episode's own screen already has the transport, so the bar steps aside + // there -- two sets of play controls on one screen would be the drift this merge removed. + bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue, hideBarFor = episodeId) }, ) { padding -> if (current == null) { Text("Episode not found", Modifier.padding(padding).padding(24.dp)) return@Scaffold } - // Whether the transport controls drive the player or just move a stored position. An - // episode can be open on this screen while something else is playing, and a scrubber - // that silently drove the wrong episode would be worse than one that did nothing. - val isPlayingThis = nowPlaying.episodeId == current.id EpisodeDetail( episode = current, detail = episodeDetailState( @@ -106,37 +111,74 @@ fun EpisodeScreen( ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name, ranges = ranges, nowPlaying = nowPlaying, + speed = speed, ), modifier = Modifier.padding(padding), - actions = EpisodeDetailActions( - play = { player.play(current); nav.openPlayer() }, - enqueue = { vm.enqueue(current.id) }, - download = { vm.download(current) }, - // Re-reads afterwards, or the screen keeps offering the choice it just acted on. - downloadFromPublisher = { vm.downloadFromPublisher(current) { scope.launch { episode = vm.episode(episodeId) } } }, - togglePlayed = { - if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id) - episode = current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null) - }, - markOlderPlayed = { vm.markPlayedUpTo(current) }, - togglePlayPause = { - if (isPlayingThis) player.togglePlayPause() else player.play(current) - }, - seekTo = { positionMs -> - // Seeking the player only works for the item it has loaded; for any other - // episode the drag is a statement about where Play should begin. - if (isPlayingThis) { - player.seekTo(positionMs) - } else { - vm.setPosition(current.id, positionMs) - episode = current.copy(positionMs = positionMs) - } - }, - ), + actions = episodeActions(vm, player, current, nowPlaying, hooks), ) } } +/** How the actions report back to the screen's own state. */ +private class EpisodeHooks( + val onSpeed: (Float) -> Unit, + val onEpisode: (EpisodeEntity) -> Unit, + val reload: () -> Unit, +) + +/** + * Wires the screen's buttons to the view models. + * + * Whether a control drives the player or just moves a stored position depends on whether this + * episode is the one playing: an episode can be open while something else plays, and a scrubber + * that silently drove the wrong episode would be worse than one that did nothing. + */ +private fun episodeActions( + vm: ListenViewModel, + player: PlayerViewModel, + current: EpisodeEntity, + nowPlaying: NowPlaying, + hooks: EpisodeHooks, +): EpisodeDetailActions { + val isPlayingThis = nowPlaying.episodeId == current.id + val seekTo = { positionMs: Long -> + // Seeking the player only works for the item it has loaded; for any other + // episode the drag is a statement about where Play should begin. + if (isPlayingThis) { + player.seekTo(positionMs) + } else { + vm.setPosition(current.id, positionMs) + hooks.onEpisode(current.copy(positionMs = positionMs)) + } + } + return EpisodeDetailActions( + enqueue = { vm.enqueue(current.id) }, + download = { vm.download(current) }, + // Re-reads afterwards, or the screen keeps offering the choice it just acted on. + downloadFromPublisher = { + vm.downloadFromPublisher(current, hooks.reload) + }, + togglePlayed = { + if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id) + hooks.onEpisode(current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null)) + }, + markOlderPlayed = { vm.markPlayedUpTo(current) }, + togglePlayPause = { if (isPlayingThis) player.togglePlayPause() else player.play(current) }, + seekTo = seekTo, + skip = { delta -> + when { + !isPlayingThis -> seekTo(Transport.stepped(current.positionMs, delta, current.playbackDurationMs)) + delta < 0 -> player.skipBack() + else -> player.skipForward() + } + }, + setSpeed = { chosen -> + hooks.onSpeed(chosen) + player.setSpeedFor(current, chosen) + }, + ) +} + @Composable private fun EpisodeDetail( episode: EpisodeEntity, @@ -158,7 +200,7 @@ private fun EpisodeDetail( .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(Modifier.weight(1f)) { - Text(episode.title, style = MaterialTheme.typography.titleMedium) + Text(episode.title, style = MaterialTheme.typography.titleMedium, maxLines = 3, overflow = TextOverflow.Ellipsis) Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) } } @@ -167,45 +209,17 @@ private fun EpisodeDetail( SkipReadout(detail.skips, detail.playback.breaks) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = actions.play) { Text(if (episode.positionMs > 0) "Resume" else "Play") } - OutlinedButton(onClick = actions.enqueue) { Text("Queue") } - OutlinedButton(onClick = actions.download) { Text("Download") } - OutlinedButton(onClick = actions.togglePlayed) { - Text(if (episode.playedAtMs == null) "Played" else "Unplayed") - } - WatchButton(WatchLink.at(episode.mediaUrl, episode.positionMs), "Watch") - } - - // The way out of a wait, and what it costs, side by side. Wording is SkipSummary's so a - // test pins it. - WaitingForServerText.publisherOffer(episode)?.let { offer -> - Text(offer.warning, style = MaterialTheme.typography.bodySmall) - OutlinedButton(onClick = actions.downloadFromPublisher, modifier = Modifier.fillMaxWidth()) { - Text(offer.label) - } - } - - // The import case: a freshly imported back catalogue is entirely unplayed, and this is - // the only practical way to say "I've heard everything up to here". - OutlinedButton(onClick = actions.markOlderPlayed, modifier = Modifier.fillMaxWidth()) { - Text("Mark this and older as played") - } + EpisodeTransport(detail.playback, detail.speed, actions) RuleAttribution(episode, detail.ruleName) - episode.description?.let { - Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium) - } + episode.description?.let { ExpandableDescription(stripHtml(it)) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EpisodeTopBar(title: String?, onBack: () -> Unit) { +private fun EpisodeTopBar(title: String?, onBack: () -> Unit, overflow: @Composable () -> Unit) { TopAppBar( title = { Text(title ?: "Episode", maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { @@ -213,6 +227,7 @@ private fun EpisodeTopBar(title: String?, onBack: () -> Unit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + actions = { overflow() }, ) } @@ -221,6 +236,8 @@ data class EpisodeDetailState( val ruleName: String?, val skips: EpisodeSkips, val playback: EpisodePlayback, + /** What this episode plays at: a rule's choice, else the show's, else normal. */ + val speed: Float, ) /** @@ -235,10 +252,13 @@ private fun episodeDetailState( ruleName: String?, ranges: List<EpisodeMidrollEntity>, nowPlaying: NowPlaying, + speed: Float, ): EpisodeDetailState { // Whether the transport controls drive the player or just describe a stored position. val isPlayingThis = nowPlaying.episodeId == episode.id - val durationMs = if (isPlayingThis) nowPlaying.durationMs else episode.playbackDurationMs + // A player that has the episode loaded but has not measured it yet (or failed to) reports + // no length; falling back to the stored one keeps the marks and the clock on screen. + val durationMs = nowPlaying.durationMs?.takeIf { isPlayingThis && it > 0 } ?: episode.playbackDurationMs return EpisodeDetailState( ruleName = ruleName, skips = SkipSummary.episode(episode, ranges), @@ -248,6 +268,7 @@ private fun episodeDetailState( isPlaying = isPlayingThis && nowPlaying.isPlaying, breaks = SkipSummary.breakMarkers(ranges, durationMs), ), + speed = speed, ) } @@ -261,138 +282,6 @@ data class EpisodePlayback( ) /** - * This episode's position, with the detected ad breaks marked on it. - * - * Separate from the player screen's scrubber on purpose: that one always drives whatever is - * playing, and this one has to work for an episode that is merely open -- where a drag sets - * where Play will begin rather than seeking anything. The drag rule is the same, though: a - * finger should not fight the live position tick, so the local value only tracks the drag and - * the seek fires once on release. - */ -@Composable -private fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) { - val duration = playback.durationMs?.takeIf { it > 0 } ?: 0L - var dragValue by remember { mutableFloatStateOf(-1f) } - val shown = if (dragValue >= 0f) dragValue else playback.positionMs.toFloat() - - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = actions.togglePlayPause) { - Icon( - if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (playback.isPlaying) "Pause" else "Play", - ) - } - Column(Modifier.weight(1f)) { - Box { - Slider( - value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)), - onValueChange = { dragValue = it }, - onValueChangeFinished = { - actions.seekTo(dragValue.toLong()) - dragValue = -1f - }, - valueRange = 0f..duration.toFloat().coerceAtLeast(1f), - enabled = duration > 0, - ) - } - BreakMarks(playback.breaks, duration) { actions.seekTo(it) } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall) - Text(formatClock(duration), style = MaterialTheme.typography.bodySmall) - } - } - } -} - -/** - * The detected breaks as bands on a strip under the slider, advertising in one colour and the - * show's own audio (intro, cue, outro) in another, decided by [SkipSummary.isAdvertising] rather - * than by comparing against [SkipSummary.describeKind]'s display string -- a renamed label must - * not silently invert which colour means advertising. A kind this version does not recognise - * draws as advertising too, matching what playback does with it. - * - * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than - * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a - * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels. - */ -@Composable -private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) { - if (breaks.isEmpty()) return - var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) } - val adColour = MaterialTheme.colorScheme.tertiary - val furnitureColour = MaterialTheme.colorScheme.secondary - val trackColour = MaterialTheme.colorScheme.surfaceVariant - val outlineColour = MaterialTheme.colorScheme.onSurface - Canvas( - Modifier - .fillMaxWidth() - .height(BREAK_STRIP_HEIGHT) - .pointerInput(breaks, durationMs) { - detectTapGestures { tap -> - val width = size.width.toFloat() - val usable = (width - THUMB_RADIUS_PX * 2).coerceAtLeast(1f) - val hit = BreakTrack.markAt( - breaks, - BreakTrack.trackFraction(tap.x, width, THUMB_RADIUS_PX), - slop = TAP_SLOP_PX / usable, - ) - picked = hit - hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) } - } - }, - ) { - // Inset by the thumb's radius at each end: the slider's track is laid out inside it, so - // marks drawn edge to edge would sit a thumb-width off the positions they describe. - val inset = THUMB_RADIUS_PX - val usable = (size.width - inset * 2).coerceAtLeast(0f) - val bandTop = (size.height - BREAK_BAND_PX) / 2 - drawRect(trackColour, Offset(inset, bandTop), Size(usable, BREAK_BAND_PX)) - breaks.forEach { mark -> - val left = inset + usable * mark.start - val right = inset + usable * mark.end - val topLeft = Offset(left, bandTop) - val bandSize = Size((right - left).coerceAtLeast(MIN_BREAK_MARK_PX), BREAK_BAND_PX) - drawRect(if (SkipSummary.isAdvertising(mark.kind)) adColour else furnitureColour, topLeft, bandSize) - if (mark == picked) drawRect(outlineColour, topLeft, bandSize, style = Stroke(width = 2f)) - } - } - picked?.let { - Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall) - } -} - -/** - * Served, declared and cut, on one line, plus a key for the two colours on the track. - * - * The check Peter asked for: the gap between the first two is the advertising in the file, and - * the third is what the detector took out, so a third that is far from that gap is the detector - * being wrong. When there is no cut to report, [EpisodeSkips.status] says why -- "not checked - * yet" is a different answer from "none found" and must stay one. Intro and outro are not here: - * the server does not publish those offsets yet, and when it does they are marks like any other. - */ -@Composable -private fun SkipReadout(skips: EpisodeSkips, breaks: List<SkipSummary.BreakMarker>) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - skips.numbers?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } - skips.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) } - if (breaks.any { SkipSummary.isAdvertising(it.kind) }) { - LegendEntry(MaterialTheme.colorScheme.tertiary, SkipSummary.LEGEND_ADVERTISING) - } - if (breaks.any { !SkipSummary.isAdvertising(it.kind) }) { - LegendEntry(MaterialTheme.colorScheme.secondary, SkipSummary.LEGEND_OWN_AUDIO) - } - } -} - -@Composable -private fun LegendEntry(colour: Color, label: String) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Box(Modifier.size(LEGEND_SWATCH).background(colour)) - Text(label, style = MaterialTheme.typography.bodySmall) - } -} - -/** * Show notes are HTML in practice, whatever the spec says. * * Rendered raw they appear as a wall of tags. This is not a parser — it strips tags, decodes the @@ -424,19 +313,3 @@ private fun RuleAttribution(episode: EpisodeEntity, ruleName: String?) { val label = if (ruleName != null) "Handled by \"$ruleName\" — $reason" else "Handled by a rule — $reason" Text(label, style = MaterialTheme.typography.bodySmall) } - -/** Tall enough to tap; the band drawn inside it is what reads as an annotation on the track. */ -private val BREAK_STRIP_HEIGHT = 28.dp - -private const val BREAK_BAND_PX = 20f - -/** How far from a band a tap still counts as hitting it. */ -private const val TAP_SLOP_PX = 24f - -private val LEGEND_SWATCH = 10.dp - -/** Material's slider thumb radius, which the track is inset by at both ends. */ -private const val THUMB_RADIUS_PX = 20f - -/** A break shorter than this would otherwise draw as nothing at all. */ -private const val MIN_BREAK_MARK_PX = 2f diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt new file mode 100644 index 0000000..87ccfac --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeTimeline.kt @@ -0,0 +1,173 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import org.terst.listen.fingerprint.BreakTrack +import org.terst.listen.fingerprint.EpisodeSkips +import org.terst.listen.fingerprint.SkipSummary + +/** + * This episode's position, with the detected ad breaks marked on it. + * + * It has to work for an episode that is merely open -- where a drag sets where Play will begin + * rather than seeking anything -- as well as the one playing. The drag rule is the same, though: a + * finger should not fight the live position tick, so the local value only tracks the drag and + * the seek fires once on release. + */ +@Composable +fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailActions) { + val duration = playback.durationMs?.takeIf { it > 0 } ?: 0L + var dragValue by remember { mutableFloatStateOf(-1f) } + val shown = if (dragValue >= 0f) dragValue else playback.positionMs.toFloat() + + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Box { + Slider( + value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)), + onValueChange = { dragValue = it }, + onValueChangeFinished = { + actions.seekTo(dragValue.toLong()) + dragValue = -1f + }, + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + enabled = duration > 0, + ) + } + BreakMarks(playback.breaks, duration) { actions.seekTo(it) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall) + Text(formatClock(duration), style = MaterialTheme.typography.bodySmall) + } + } + } +} + +/** + * The detected breaks as bands on a strip under the slider, advertising in one colour and the + * show's own audio (intro, cue, outro) in another, decided by [SkipSummary.isAdvertising] rather + * than by comparing against [SkipSummary.describeKind]'s display string -- a renamed label must + * not silently invert which colour means advertising. A kind this version does not recognise + * draws as advertising too, matching what playback does with it. + * + * Tapping a band names it and seeks to its start. The strip is its own, taller target rather than + * a hairline behind the slider, because a break is a few pixels wide and a finger is not; what a + * tap hits is decided by [BreakTrack.markAt], so the composable only converts pixels. + */ +@Composable +private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, durationMs: Long, onSeek: (Long) -> Unit) { + if (breaks.isEmpty()) return + var picked by remember(breaks) { mutableStateOf<SkipSummary.BreakMarker?>(null) } + val adColour = MaterialTheme.colorScheme.tertiary + val furnitureColour = MaterialTheme.colorScheme.secondary + val trackColour = MaterialTheme.colorScheme.surfaceVariant + val outlineColour = MaterialTheme.colorScheme.onSurface + Canvas( + Modifier + .fillMaxWidth() + .height(BREAK_STRIP_HEIGHT) + .pointerInput(breaks, durationMs) { + detectTapGestures { tap -> + val width = size.width.toFloat() + val usable = (width - THUMB_RADIUS_PX * 2).coerceAtLeast(1f) + val hit = BreakTrack.markAt( + breaks, + BreakTrack.trackFraction(tap.x, width, THUMB_RADIUS_PX), + slop = TAP_SLOP_PX / usable, + ) + picked = hit + hit?.let { onSeek(BreakTrack.markStartMs(it, durationMs)) } + } + }, + ) { + // Inset by the thumb's radius at each end: the slider's track is laid out inside it, so + // marks drawn edge to edge would sit a thumb-width off the positions they describe. + val inset = THUMB_RADIUS_PX + val usable = (size.width - inset * 2).coerceAtLeast(0f) + val bandTop = (size.height - BREAK_BAND_PX) / 2 + drawRect(trackColour, Offset(inset, bandTop), Size(usable, BREAK_BAND_PX)) + breaks.forEach { mark -> + val left = inset + usable * mark.start + val right = inset + usable * mark.end + val topLeft = Offset(left, bandTop) + val bandSize = Size((right - left).coerceAtLeast(MIN_BREAK_MARK_PX), BREAK_BAND_PX) + drawRect(if (SkipSummary.isAdvertising(mark.kind)) adColour else furnitureColour, topLeft, bandSize) + if (mark == picked) drawRect(outlineColour, topLeft, bandSize, style = Stroke(width = 2f)) + } + } + picked?.let { + Text(BreakTrack.describeMark(it, durationMs), style = MaterialTheme.typography.bodySmall) + } +} + +/** + * Served, declared and cut, on one line, plus a key for the two colours on the track. + * + * The check Peter asked for: the gap between the first two is the advertising in the file, and + * the third is what the detector took out, so a third that is far from that gap is the detector + * being wrong. When there is no cut to report, [EpisodeSkips.status] says why -- "not checked + * yet" is a different answer from "none found" and must stay one. Intro and outro are not here: + * the server does not publish those offsets yet, and when it does they are marks like any other. + */ +@Composable +fun SkipReadout(skips: EpisodeSkips, breaks: List<SkipSummary.BreakMarker>) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + skips.numbers?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + skips.status?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + if (breaks.any { SkipSummary.isAdvertising(it.kind) }) { + LegendEntry(MaterialTheme.colorScheme.tertiary, SkipSummary.LEGEND_ADVERTISING) + } + if (breaks.any { !SkipSummary.isAdvertising(it.kind) }) { + LegendEntry(MaterialTheme.colorScheme.secondary, SkipSummary.LEGEND_OWN_AUDIO) + } + } +} + +@Composable +private fun LegendEntry(colour: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.size(LEGEND_SWATCH).background(colour)) + Text(label, style = MaterialTheme.typography.bodySmall) + } +} + +/** Tall enough to tap; the band drawn inside it is what reads as an annotation on the track. */ +private val BREAK_STRIP_HEIGHT = 28.dp + +private const val BREAK_BAND_PX = 20f + +/** How far from a band a tap still counts as hitting it. */ +private const val TAP_SLOP_PX = 24f + +private val LEGEND_SWATCH = 10.dp + +/** Material's slider thumb radius, which the track is inset by at both ends. */ +private const val THUMB_RADIUS_PX = 20f + +/** A break shorter than this would otherwise draw as nothing at all. */ +private const val MIN_BREAK_MARK_PX = 2f diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt new file mode 100644 index 0000000..3a56c9d --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/EpisodeTransport.kt @@ -0,0 +1,92 @@ +package org.terst.listen.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.dp + +/** + * The one transport: back, the single primary control, forward, and the speed. + * + * The primary button's wording says what tapping it will do (Play, Resume, Pause) and it is the + * only play control on the screen. Speed sits behind a tap showing the current value, so a row + * of four chips no longer takes a full line. + */ +@Composable +fun EpisodeTransport(playback: EpisodePlayback, speed: Float, actions: EpisodeDetailActions) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { actions.skip(-Transport.BACK_MS) }) { + Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds") + } + Button(onClick = actions.togglePlayPause) { + Icon( + if (playback.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Text(Transport.primaryLabel(playback.isPlaying, playback.positionMs), Modifier.padding(start = 6.dp)) + } + IconButton(onClick = { actions.skip(Transport.FORWARD_MS) }) { + Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds") + } + SpeedChip(speed, actions.setSpeed) + } +} + +private val SPEED_PADDING = PaddingValues(horizontal = 12.dp, vertical = 6.dp) + +@Composable +private fun SpeedChip(current: Float, onSelect: (Float) -> Unit) { + var open by remember { mutableStateOf(false) } + val selected = SpeedChoices.selected(current) + Box { + OutlinedButton(onClick = { open = true }, contentPadding = SPEED_PADDING) { + Text(speedLabel(current)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + SpeedChoices.OFFERED.forEach { option -> + DropdownMenuItem( + text = { Text(speedLabel(option), style = MaterialTheme.typography.bodyLarge) }, + leadingIcon = { + if (option == selected) Icon(Icons.Default.Check, contentDescription = "Current speed") + else Box(Modifier.width(24.dp)) + }, + onClick = { + open = false + onSelect(option) + }, + ) + } + } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/LibraryNav.kt b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt index d7a636f..15ec95b 100644 --- a/app/src/main/java/org/terst/listen/ui/LibraryNav.kt +++ b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt @@ -3,7 +3,7 @@ package org.terst.listen.ui /** Where the library screen's actions navigate to, bundled so the signature stays readable. */ data class LibraryNav( val openSubscription: (String) -> Unit, - val openPlayer: () -> Unit, + val openEpisode: (String) -> Unit, val openQueue: () -> Unit, val openSettings: () -> Unit, ) 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 a3a1149..1deb2e9 100644 --- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt @@ -99,7 +99,7 @@ fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) togglePlayPause = player::togglePlayPause, skipBack = player::skipBack, skipForward = player::skipForward, - openPlayer = nav.openPlayer, + openEpisode = { nowPlaying.episodeId?.let(nav.openEpisode) }, openQueue = nav.openQueue, ), ) 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 2558937..d89cde3 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -91,7 +91,7 @@ private fun ListenApp( ) { val nav = rememberNavController() - ShowPlayerWhenShared(player, nav) + ShowEpisodeWhenShared(player, nav) NavHost(navController = nav, startDestination = "library") { composable("library") { @@ -100,15 +100,12 @@ private fun ListenApp( player = player, nav = LibraryNav( openSubscription = { nav.navigate("subscription/$it") }, - openPlayer = { nav.navigate("player") }, + openEpisode = { nav.navigate("episode/$it") }, openQueue = { nav.navigate("queue") }, openSettings = { nav.navigate("settings") }, ), ) } - composable("player") { - PlayerScreen(player = player, onBack = { nav.popBackStack() }) - } episodeRoute(nav, viewModel, player) settingsRoute(nav, opmlViewModel, player) rulesRoutes(nav, rulesViewModel) @@ -118,7 +115,6 @@ private fun ListenApp( player = player, nav = QueueNav( openEpisode = { nav.navigate("episode/$it") }, - openPlayer = { nav.navigate("player") }, openQueue = {}, back = { nav.popBackStack() }, ), @@ -136,8 +132,7 @@ private fun ListenApp( subscriptionId = id, nav = SubscriptionNav( openEpisode = { nav.navigate("episode/$it") }, - openPlayer = { nav.navigate("player") }, - openQueue = { nav.navigate("queue") }, + openQueue = { nav.navigate("queue") }, back = { nav.popBackStack() }, ), ) @@ -147,19 +142,19 @@ private fun ListenApp( } /** - * Opens the player when playback was started from a share. + * Opens the episode when playback was started from a share. * * The share activity has no UI and no navigation controller, and the app may not have been * running at all when the link arrived, so it cannot navigate. It starts playback and this * reacts once there is something to navigate. */ @Composable -private fun ShowPlayerWhenShared(player: PlayerViewModel, nav: androidx.navigation.NavHostController) { - val openPlayer by player.openPlayer.collectAsState() - LaunchedEffect(openPlayer) { - if (!openPlayer) return@LaunchedEffect - player.openPlayerHandled() - nav.navigate("player") +private fun ShowEpisodeWhenShared(player: PlayerViewModel, nav: androidx.navigation.NavHostController) { + val episodeId by player.openEpisode.collectAsState() + LaunchedEffect(episodeId) { + val id = episodeId ?: return@LaunchedEffect + player.openEpisodeHandled() + nav.navigate("episode/$id") { launchSingleTop = true } } } @@ -185,8 +180,7 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute( episodeId = id, nav = SubscriptionNav( openEpisode = { nav.navigate("episode/$it") }, - openPlayer = { nav.navigate("player") }, - openQueue = { nav.navigate("queue") }, + openQueue = { nav.navigate("queue") }, back = { nav.popBackStack() }, ), ) diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt index f3deb3f..bc6cba0 100644 --- a/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt +++ b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt @@ -5,6 +5,6 @@ data class NowPlayingActions( val togglePlayPause: () -> Unit, val skipBack: () -> Unit, val skipForward: () -> Unit, - val openPlayer: () -> Unit, + val openEpisode: () -> Unit, val openQueue: () -> Unit, ) diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt index 450d70a..482fa86 100644 --- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt +++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt @@ -63,10 +63,10 @@ fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) { .size(56.dp) .clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = actions.openPlayer), + .clickable(onClick = actions.openEpisode), ) - NowPlayingText(state, Modifier.weight(1f), actions.openPlayer) + NowPlayingText(state, Modifier.weight(1f), actions.openEpisode) IconButton(onClick = actions.skipBack) { Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds") diff --git a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt index d9b8652..76679dc 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerBar.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerBar.kt @@ -19,13 +19,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp /** - * The now-playing bar as a screen's bottom bar. + * The now-playing bar as a screen's bottom bar. [hideBarFor] is the episode whose own screen this + * is; the bar is left off there because that screen is the player. * * One place rather than repeated per screen: the bar follows you around the app, and three * copies of the same wiring is three chances for them to drift. */ @Composable -fun PlayerBar(player: PlayerViewModel, onOpenPlayer: () -> Unit, onOpenQueue: () -> Unit) { +fun PlayerBar( + player: PlayerViewModel, + onOpenEpisode: (String) -> Unit, + onOpenQueue: () -> Unit, + hideBarFor: String? = null, +) { val nowPlaying by player.nowPlaying.collectAsState() val preparing by player.preparing.collectAsState() val banner by player.banner.collectAsState() @@ -44,14 +50,16 @@ fun PlayerBar(player: PlayerViewModel, onOpenPlayer: () -> Unit, onOpenQueue: () val fetching = preparing if (fetching != null) { PreparingBar(fetching) - } else { + } else if (hideBarFor == null || nowPlaying.episodeId != hideBarFor) { + // Not on the playing episode's own screen, which carries the same controls. NowPlayingBar( state = nowPlaying, actions = NowPlayingActions( togglePlayPause = player::togglePlayPause, skipBack = player::skipBack, skipForward = player::skipForward, - openPlayer = onOpenPlayer, + // The episode screen is the player: the bar opens whatever it is showing. + openEpisode = { nowPlaying.episodeId?.let(onOpenEpisode) }, openQueue = onOpenQueue, ), ) diff --git a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt deleted file mode 100644 index b826406..0000000 --- a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt +++ /dev/null @@ -1,247 +0,0 @@ -package org.terst.listen.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.FastForward -import androidx.compose.material.icons.filled.FastRewind -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import org.terst.listen.feed.Chapter - -/** Offered speeds. 1.0x stays in the list as the way back to normal. */ -private val SPEEDS = listOf(1.0f, 1.1f, 1.25f, 2.0f) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) { - val nowPlaying by player.nowPlaying.collectAsState() - val show by player.currentShow.collectAsState() - val chapters by player.chapters.collectAsState() - val banner by player.banner.collectAsState() - val preparing by player.preparing.collectAsState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text(show?.title ?: "Now Playing", maxLines = 1, overflow = TextOverflow.Ellipsis) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Column( - Modifier.padding(padding).fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - AsyncImage( - model = nowPlaying.artworkUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) - - PlayerMessage(banner, onDismiss = player::dismissBanner) - - // Tapping Play navigates straight here, so this is the screen that has to say a - // fetch is running -- otherwise a yt-dlp extraction reads as "Nothing playing". - Text( - preparing?.let { "Fetching audio — $it" } ?: nowPlaying.title ?: "Nothing playing", - style = MaterialTheme.typography.titleLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - Scrubber( - positionMs = nowPlaying.positionMs, - durationMs = nowPlaying.durationMs, - onSeek = player::seekTo, - ) - - TransportRow(isPlaying = nowPlaying.isPlaying, player = player) - - // Keyed on whole seconds: the link only carries seconds, so rebuilding it on - // every position tick would be churn for an identical string. - val watchLink = remember(nowPlaying.positionMs / MS_PER_SECOND) { player.watchLink() } - WatchButton(watchLink, "Watch from here") - - if (show != null) { - SpeedRow(current = show?.playbackSpeed ?: nowPlaying.speed, onSelect = player::setShowSpeed) - } - - ChapterList( - chapters = chapters, - positionMs = nowPlaying.positionMs, - onSeek = player::seekTo, - ) - } - } -} - -private const val MS_PER_SECOND = 1000L - -/** - * The episode's chapters, if it has any. - * - * Renders nothing at all when it does not, which is most shows -- an empty "Chapters" heading on - * every episode would be a permanent reminder of a feature that rarely applies. - */ -@Composable -private fun ColumnScope.ChapterList(chapters: List<Chapter>, positionMs: Long, onSeek: (Long) -> Unit) { - if (chapters.isEmpty()) return - - val currentIndex = chapters.indexOfLast { it.startMs <= positionMs } - - Column(Modifier.fillMaxWidth().weight(1f, fill = false).verticalScroll(rememberScrollState())) { - Text("Chapters", style = MaterialTheme.typography.titleSmall) - chapters.forEachIndexed { index, chapter -> - Row( - Modifier.fillMaxWidth().clickable { onSeek(chapter.startMs) }.padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - formatClock(chapter.startMs), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(end = 12.dp), - ) - Text( - chapter.title ?: "Chapter ${index + 1}", - style = if (index == currentIndex) { - MaterialTheme.typography.bodyMedium - } else { - MaterialTheme.typography.bodySmall - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - // Marked so a skipped section is explained rather than just vanishing. - if (!chapter.inToc) { - Text("break", style = MaterialTheme.typography.bodySmall) - } - } - } - } -} - -@Composable -private fun TransportRow(isPlaying: Boolean, player: PlayerViewModel) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = player::skipBack) { - Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds") - } - IconButton(onClick = player::togglePlayPause) { - Icon( - if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(12.dp), - ) - } - IconButton(onClick = player::skipForward) { - Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds") - } - } -} - -/** - * A dragging finger shouldn't fight the live position tick, so the local value only tracks the - * drag while one is in progress; the seek itself only fires once the drag ends. - */ -@Composable -private fun Scrubber(positionMs: Long, durationMs: Long?, onSeek: (Long) -> Unit) { - val duration = durationMs?.takeIf { it > 0 } ?: 0L - var dragValue by remember { mutableFloatStateOf(-1f) } - val shown = if (dragValue >= 0f) dragValue else positionMs.toFloat() - - Column(Modifier.fillMaxWidth()) { - Slider( - value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)), - onValueChange = { dragValue = it }, - onValueChangeFinished = { - onSeek(dragValue.toLong()) - dragValue = -1f - }, - valueRange = 0f..duration.toFloat().coerceAtLeast(1f), - enabled = duration > 0, - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall) - Text(formatClock(duration), style = MaterialTheme.typography.bodySmall) - } - } -} - -@Composable -private fun SpeedRow(current: Float, onSelect: (Float) -> Unit) { - // Wraps rather than one fixed row: five chips at these label widths overflowed the screen. - // Compact labels too -- "1.25x" reads the same as "1.25" with a multiplication sign and - // buys back the width that caused the overflow. - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - SPEEDS.forEach { speed -> - val label = speedLabel(speed) - if (current == speed) { - Button(onClick = { onSelect(speed) }, contentPadding = SPEED_PADDING) { Text(label) } - } else { - OutlinedButton(onClick = { onSelect(speed) }, contentPadding = SPEED_PADDING) { Text(label) } - } - } - } -} - -private val SPEED_PADDING = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 6.dp) - -/** Drops the trailing zero so 1.0x and 1.5x are as short as they can be. */ -internal fun speedLabel(speed: Float): String { - val text = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString().trimEnd('0').trimEnd('.') - return "${text}\u00d7" -} diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt index 81d2ecc..df9bcc1 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -7,15 +7,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.Graph -import org.terst.listen.data.SubscriptionEntity import org.terst.listen.feed.Chapter -import org.terst.listen.feed.WatchLink import org.terst.listen.playback.NowPlaying +import org.terst.listen.playback.PlaybackSpeed import org.terst.listen.playback.PlayerConnection import org.terst.listen.playback.StartOutcome import org.terst.listen.playback.BedtimeAction @@ -50,22 +47,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { private val _preparing = MutableStateFlow<String?>(null) val preparing: StateFlow<String?> = _preparing.asStateFlow() - /** - * The playing episode, so the watch link can be built without suspending. - * - * Declared here rather than beside loadContext: the collector in init emits the current - * value synchronously, so a property declared after init is still null when it first runs. - */ - private var currentEpisode: EpisodeEntity? = null - - /** Chapters for whatever is playing, empty when the show publishes none. */ - private val _chapters = MutableStateFlow<List<Chapter>>(emptyList()) - val chapters: StateFlow<List<Chapter>> = _chapters.asStateFlow() - - /** The show behind whatever is currently playing, for the player screen's speed control. */ - private val _currentShow = MutableStateFlow<SubscriptionEntity?>(null) - val currentShow: StateFlow<SubscriptionEntity?> = _currentShow.asStateFlow() - init { // Position advances with no callback of its own, so the bar is driven by a tick. viewModelScope.launch { @@ -74,50 +55,39 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { connection.publish() } } - - // Follow whatever is playing rather than only what this screen started. The service - // moves the queue on by itself, so the show and chapters shown here have to come from - // the player, not from the last call to play(). - viewModelScope.launch { - connection.nowPlaying - .map { it.episodeId } - .distinctUntilChanged() - .collect { episodeId -> loadContext(episodeId) } - } } - private suspend fun loadContext(episodeId: String?) { - val episode = episodeId?.let { graph.episodes.getById(it) } - currentEpisode = episode - if (episode == null) { - _currentShow.value = null - _chapters.value = emptyList() - return - } - _currentShow.value = graph.subscriptions.getById(episode.subscriptionId) - // Cached on the episode after the first fetch, so this is a database read in the common - // case even though the service may have fetched it already. - _chapters.value = graph.chapters.chapters(episode) - } + /** + * What an episode plays at: a rule's choice, else the show's, else normal. + * + * Whatever is stored, as stored -- 1.5 and 1.75 survive from before the speed list was + * trimmed, and nothing here rounds them onto an offered value. + */ + suspend fun speedFor(episode: EpisodeEntity): Float = + PlaybackSpeed.forEpisode(episode.speedOverride, graph.subscriptions.getById(episode.subscriptionId)?.playbackSpeed) + + /** The episode's chapters, empty when its show publishes none. Cached after the first fetch. */ + suspend fun chaptersFor(episode: EpisodeEntity): List<Chapter> = graph.chapters.chapters(episode) override fun onCleared() { connection.release() super.onCleared() } - private val _openPlayer = MutableStateFlow(false) + private val _openEpisode = MutableStateFlow<String?>(null) /** - * Set when playback was started from outside the UI, so the app can show the player. + * The episode to show because playback was started from outside the UI. * * A share arrives at an activity that may not be running; there is no screen to navigate * from. This lets the nav graph react once the UI does exist, rather than the share - * activity trying to drive navigation it knows nothing about. + * activity trying to drive navigation it knows nothing about. The episode screen is the + * player, so this names the episode to open. */ - val openPlayer: StateFlow<Boolean> = _openPlayer.asStateFlow() + val openEpisode: StateFlow<String?> = _openEpisode.asStateFlow() - fun openPlayerHandled() { - _openPlayer.value = false + fun openEpisodeHandled() { + _openEpisode.value = null } /** @@ -130,7 +100,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { fun playShared(episodeId: String) { viewModelScope.launch { val episode = graph.episodes.getById(episodeId) ?: return@launch - _openPlayer.value = true + _openEpisode.value = episodeId play(episode) } } @@ -186,39 +156,25 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { } } - /** - * The same moment, on the site the video came from. - * - * Null for an ordinary podcast, which has no page to hand off to. The position comes from - * the player rather than the stored one, so it is where the listener actually is. - */ - fun watchLink(): String? { - val playing = connection.nowPlaying.value - val episode = currentEpisode?.takeIf { it.id == playing.episodeId } ?: return null - return WatchLink.at(episode.mediaUrl, playing.positionMs) - } - fun togglePlayPause() = connection.togglePlayPause() - fun skipBack() = connection.seekBy(-SKIP_BACK_MS) + fun skipBack() = connection.seekBy(-Transport.BACK_MS) - fun skipForward() = connection.seekBy(SKIP_FORWARD_MS) + fun skipForward() = connection.seekBy(Transport.FORWARD_MS) fun setSpeed(speed: Float) = connection.setSpeed(speed) - /** Player screen's scrubber, committed once the drag ends rather than on every move. */ + /** The scrubber, committed once the drag ends rather than on every move. */ fun seekTo(positionMs: Long) = connection.seekTo(positionMs) /** - * Sets the speed for whatever show is currently playing, persisted so it survives to the - * next episode from that show. This is what makes [SubscriptionEntity.playbackSpeed] - * reachable -- until the player screen, only the live, unpersisted [setSpeed] existed. + * Sets the speed for the show behind [episode], persisted so it survives to the next episode + * from that show, and applies it at once when that episode is the one playing. This is what + * makes [org.terst.listen.data.SubscriptionEntity.playbackSpeed] reachable from the UI. */ - fun setShowSpeed(speed: Float) { - val show = _currentShow.value ?: return - viewModelScope.launch { graph.subscriptions.updateSpeed(show.id, speed) } - _currentShow.value = show.copy(playbackSpeed = speed) - connection.setSpeed(speed) + fun setSpeedFor(episode: EpisodeEntity, speed: Float) { + viewModelScope.launch { graph.subscriptions.updateSpeed(episode.subscriptionId, speed) } + if (connection.nowPlaying.value.episodeId == episode.id) connection.setSpeed(speed) } // --- sleep timer --------------------------------------------------------------------- @@ -289,12 +245,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { private companion object { const val PROGRESS_TICK_MS = 1_000L - // Asymmetric on purpose: back is for catching a missed sentence, forward for skipping - // an ad break. - // Identical to the service's seek increments, so the in-app bar and the lock - // screen never disagree about what a skip does. - const val SKIP_BACK_MS = 15_000L - const val SKIP_FORWARD_MS = 25_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 c53fd5b..119f6ee 100644 --- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt @@ -75,7 +75,7 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, nav: QueueNav) { }, ) }, - bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) }, + bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue) }, ) { padding -> LazyColumn(state = lazyListState, modifier = Modifier.padding(padding).fillMaxSize()) { items(localOrder, key = { it.id }) { episode -> @@ -140,7 +140,6 @@ private fun ReorderableCollectionItemScope.QueueRow( /** Where the queue screen navigates to, bundled like [LibraryNav] and [SubscriptionNav]. */ data class QueueNav( val openEpisode: (String) -> Unit, - val openPlayer: () -> Unit, val openQueue: () -> Unit, val back: () -> Unit, ) diff --git a/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt b/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt new file mode 100644 index 0000000..0c49c82 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SpeedChoices.kt @@ -0,0 +1,23 @@ +package org.terst.listen.ui + +/** + * The speeds the episode screen offers. + * + * Offering four does not limit what can play: a show or rule can already be storing 1.5 or 1.75 + * from before the list was trimmed, and [org.terst.listen.playback.PlaybackSpeed] resolves + * whatever is stored without consulting this list. This is presentation only. + */ +object SpeedChoices { + + /** 1.0x stays in the list as the way back to normal. */ + val OFFERED: List<Float> = listOf(1.0f, 1.1f, 1.25f, 2.0f) + + /** The offered speed that [current] is, or null when it is stored but not offered (1.5, 1.75). */ + fun selected(current: Float): Float? = OFFERED.firstOrNull { it == current } +} + +/** Drops the trailing zero so 1.0x and 1.5x are as short as they can be. */ +internal fun speedLabel(speed: Float): String { + val text = if (speed % 1f == 0f) speed.toInt().toString() else speed.toString().trimEnd('0').trimEnd('.') + return "${text}×" +} 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 c93f353..7712bf1 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -96,7 +96,7 @@ fun SubscriptionScreen( ) }, snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } }, - bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) }, + bottomBar = { PlayerBar(player, nav.openEpisode, nav.openQueue) }, ) { padding -> // Confirmed, because unsubscribing cascades: the episodes go, and with them every // playback position and played mark for that show. None of it is recoverable. @@ -263,7 +263,6 @@ data class SubscriptionNav( // button that does nothing -- which is exactly what happened: tapping an episode title // silently did nothing because one route forgot to pass this. val openEpisode: (String) -> Unit, - val openPlayer: () -> Unit, val openQueue: () -> Unit, val back: () -> Unit, ) diff --git a/app/src/main/java/org/terst/listen/ui/Transport.kt b/app/src/main/java/org/terst/listen/ui/Transport.kt new file mode 100644 index 0000000..80c1018 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/Transport.kt @@ -0,0 +1,21 @@ +package org.terst.listen.ui + +/** The episode screen's transport arithmetic, kept out of the composable so it can be tested. */ +object Transport { + + const val BACK_MS = 15_000L + const val FORWARD_MS = 25_000L + + /** Where a skip lands, kept inside the episode. */ + fun stepped(positionMs: Long, deltaMs: Long, durationMs: Long?): Long { + val target = (positionMs + deltaMs).coerceAtLeast(0L) + return if (durationMs != null && durationMs > 0) target.coerceAtMost(durationMs) else target + } + + /** What the one primary control says. */ + fun primaryLabel(isPlaying: Boolean, positionMs: Long): String = when { + isPlaying -> "Pause" + positionMs > 0 -> "Resume" + else -> "Play" + } +} diff --git a/app/src/main/java/org/terst/listen/ui/WatchButton.kt b/app/src/main/java/org/terst/listen/ui/WatchButton.kt deleted file mode 100644 index 710207e..0000000 --- a/app/src/main/java/org/terst/listen/ui/WatchButton.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.terst.listen.ui - -import android.content.Intent -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri - -/** - * Hands off to the site a video came from, at the point reached here. - * - * Shared by the player and the episode screen. The episode screen matters as much as the - * player: an episode that cannot be fetched -- YouTube declining to serve it, say -- never - * starts playing, and that is exactly when watching it elsewhere is the point. - * - * Renders nothing when there is no link, which is every ordinary podcast. - */ -@Composable -fun WatchButton(link: String?, label: String) { - if (link == null) return - val context = LocalContext.current - - OutlinedButton(onClick = { - // Not restricted to a package: whichever app claims the link handles it, falling back - // to a browser. - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) } - }) { Text(label) } -} diff --git a/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt b/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt new file mode 100644 index 0000000..6e9d9c1 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/DescriptionPreviewTest.kt @@ -0,0 +1,58 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DescriptionPreviewTest { + + @Test + fun `a short description is shown whole`() { + val p = DescriptionPreview.collapse("A short note.") + assertEquals("A short note.", p.text) + assertFalse(p.truncated) + } + + @Test + fun `two lines fit`() { + val p = DescriptionPreview.collapse("First.\nSecond.") + assertEquals("First.\nSecond.", p.text) + assertFalse(p.truncated) + } + + @Test + fun `a third line is cut and the rest is offered`() { + val p = DescriptionPreview.collapse("First.\nSecond.\nThird.") + assertEquals("First.\nSecond.…", p.text) + assertTrue(p.truncated) + } + + @Test + fun `blank lines do not use up the allowance`() { + val p = DescriptionPreview.collapse("First.\n\n\nSecond.") + assertEquals("First.\nSecond.", p.text) + assertFalse(p.truncated) + } + + @Test + fun `one long paragraph is cut at a word boundary within the budget`() { + val long = "word ".repeat(100).trim() + val p = DescriptionPreview.collapse(long) + assertTrue(p.truncated) + assertTrue(p.text.endsWith("word…")) + assertTrue(p.text.length <= DescriptionPreview.MAX_CHARS + 1) + } + + @Test + fun `an unbroken string is hard cut rather than dropped`() { + val p = DescriptionPreview.collapse("x".repeat(500)) + assertTrue(p.truncated) + assertEquals(DescriptionPreview.MAX_CHARS + 1, p.text.length) + } + + @Test + fun `an empty description previews as nothing`() { + assertEquals(DescriptionPreview.Preview("", false), DescriptionPreview.collapse("")) + } +} diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt new file mode 100644 index 0000000..ca20319 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt @@ -0,0 +1,80 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.ui.EpisodeMenuAction.CHAPTERS +import org.terst.listen.ui.EpisodeMenuAction.DOWNLOAD +import org.terst.listen.ui.EpisodeMenuAction.MARK_OLDER_PLAYED +import org.terst.listen.ui.EpisodeMenuAction.PUBLISHER_COPY +import org.terst.listen.ui.EpisodeMenuAction.QUEUE +import org.terst.listen.ui.EpisodeMenuAction.TOGGLE_PLAYED +import org.terst.listen.ui.EpisodeMenuAction.WATCH + +class EpisodeMenuTest { + + private fun episode(state: DownloadState = DownloadState.NONE, playedAtMs: Long? = null) = EpisodeEntity( + id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3", + mediaType = "audio/mpeg", discoveredAtMs = 0, downloadState = state, playedAtMs = playedAtMs, + ) + + private fun actions(e: EpisodeEntity, chapters: Boolean = false, link: String? = null) = + EpisodeMenu.items(e, chapters, link).map { it.action } + + @Test + fun `an ordinary episode offers the everyday actions and nothing conditional`() { + assertEquals(listOf(QUEUE, DOWNLOAD, TOGGLE_PLAYED, MARK_OLDER_PLAYED), actions(episode())) + } + + @Test + fun `a waiting episode offers the publisher's copy with its existing label`() { + val items = EpisodeMenu.items(episode(DownloadState.WAITING_FOR_SERVER), false, null) + assertEquals("Download from publisher instead", items.single { it.action == PUBLISHER_COPY }.label) + } + + @Test + fun `no other state offers the publisher's copy`() { + DownloadState.entries.filter { it != DownloadState.WAITING_FOR_SERVER }.forEach { + assertFalse("$it", PUBLISHER_COPY in actions(episode(it))) + } + } + + @Test + fun `a downloaded episode does not offer the publisher's copy`() { + assertFalse(PUBLISHER_COPY in actions(episode(DownloadState.DOWNLOADED))) + } + + @Test + fun `chapters appear only when the episode has some`() { + assertFalse(CHAPTERS in actions(episode(), chapters = false)) + assertTrue(CHAPTERS in actions(episode(), chapters = true)) + } + + @Test + fun `watch appears only for an episode with a link`() { + assertFalse(WATCH in actions(episode(), link = null)) + assertTrue(WATCH in actions(episode(), link = "https://youtu.be/x")) + } + + @Test + fun `the played entry says what tapping it will do`() { + assertEquals("Played", EpisodeMenu.items(episode(), false, null).single { it.action == TOGGLE_PLAYED }.label) + assertEquals( + "Unplayed", + EpisodeMenu.items(episode(playedAtMs = 5L), false, null).single { it.action == TOGGLE_PLAYED }.label, + ) + } + + @Test + fun `the wording the buttons had is kept`() { + val labels = EpisodeMenu.items(episode(), true, "https://youtu.be/x").associate { it.action to it.label } + assertEquals("Queue", labels[QUEUE]) + assertEquals("Download", labels[DOWNLOAD]) + assertEquals("Watch", labels[WATCH]) + assertEquals("Mark this and older as played", labels[MARK_OLDER_PLAYED]) + assertEquals("Chapters", labels[CHAPTERS]) + } +} diff --git a/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt b/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt new file mode 100644 index 0000000..fbf0489 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/SpeedChoicesTest.kt @@ -0,0 +1,44 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.terst.listen.playback.PlaybackSpeed + +/** + * The speed chip offers four speeds, but anything already stored has to keep playing at exactly + * what was stored: the list is presentation, not a set of allowed values. + */ +class SpeedChoicesTest { + + @Test + fun `the offered speeds are exactly these four`() { + assertEquals(listOf(1.0f, 1.1f, 1.25f, 2.0f), SpeedChoices.OFFERED) + } + + @Test + fun `an offered speed is the selected one`() { + SpeedChoices.OFFERED.forEach { assertEquals(it, SpeedChoices.selected(it)) } + } + + @Test + fun `a stored speed that is not offered selects nothing but is not rewritten`() { + assertNull(SpeedChoices.selected(1.5f)) + assertNull(SpeedChoices.selected(1.75f)) + } + + @Test + fun `every stored speed still resolves to itself for the show and for a rule`() { + listOf(1.0f, 1.1f, 1.25f, 1.5f, 1.75f, 2.0f).forEach { stored -> + assertEquals(stored, PlaybackSpeed.forEpisode(episodeOverride = null, showSpeed = stored)) + assertEquals(stored, PlaybackSpeed.forEpisode(episodeOverride = stored, showSpeed = 1.0f)) + } + } + + @Test + fun `the chip names a stored speed that is not offered`() { + assertEquals("1.5×", speedLabel(1.5f)) + assertEquals("1.75×", speedLabel(1.75f)) + assertEquals("1.25×", speedLabel(1.25f)) + } +} diff --git a/app/src/test/java/org/terst/listen/ui/TransportTest.kt b/app/src/test/java/org/terst/listen/ui/TransportTest.kt new file mode 100644 index 0000000..a428911 --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/TransportTest.kt @@ -0,0 +1,38 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransportTest { + + @Test + fun `the steps match the lock screen's`() { + assertEquals(15_000L, Transport.BACK_MS) + assertEquals(25_000L, Transport.FORWARD_MS) + } + + @Test + fun `a skip moves by the step`() { + assertEquals(85_000L, Transport.stepped(100_000, -Transport.BACK_MS, 600_000)) + assertEquals(125_000L, Transport.stepped(100_000, Transport.FORWARD_MS, 600_000)) + } + + @Test + fun `a skip stays inside the episode`() { + assertEquals(0L, Transport.stepped(5_000, -Transport.BACK_MS, 600_000)) + assertEquals(600_000L, Transport.stepped(590_000, Transport.FORWARD_MS, 600_000)) + } + + @Test + fun `an unknown length only clamps at the start`() { + assertEquals(0L, Transport.stepped(5_000, -Transport.BACK_MS, null)) + assertEquals(30_000L, Transport.stepped(5_000, Transport.FORWARD_MS, null)) + } + + @Test + fun `the primary control says what it will do`() { + assertEquals("Pause", Transport.primaryLabel(isPlaying = true, positionMs = 5_000)) + assertEquals("Resume", Transport.primaryLabel(isPlaying = false, positionMs = 5_000)) + assertEquals("Play", Transport.primaryLabel(isPlaying = false, positionMs = 0)) + } +} |
