diff options
| author | Claude <noreply@anthropic.com> | 2026-09-21 19:03:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-21 19:03:24 +0000 |
| commit | 42fce8b0293f78e8cf9f60a0bf2c4cd1bd010a5a (patch) | |
| tree | 1701398ee2cb40e81a1751a2ab0f5339c7f907df | |
| parent | 2ce4d50eb8b17c751d347865473f9895edecd938 (diff) | |
Icon actions, dark-only theme, real media metadata
Five labelled buttons overflowed the episode row on a phone and the last one
collapsed into a one-letter-per-line column. They are icons now, each keeping
a contentDescription so the row reads the same to a screen reader as it did
with labels. Verified on the emulator against the real NPR feed: seven
episodes visible where four fitted before.
Dark only, not a dark default with a light alternative. This is a listening
app used in the dark -- in bed with a sleep timer running, or in a car -- so
the light theme is the one nobody would pick and it isn't built. The
unqualified resources carry the dark values too, otherwise the window flashes
white before Compose draws its first frame.
Media metadata was title-only, so the lock screen and any car head unit showed
blank lines where artist and album belong. It now carries the show as artist
and album, plus the artwork URI.
Seek increments are set on the player itself, which is what the notification,
lock screen, Bluetooth remote and car controls actually use -- they were
running on Media3's defaults, not the app's. Back 15, forward 25, and the
in-app bar now uses the same numbers so the two can never disagree about what
a skip does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
| -rw-r--r-- | .coverage-baseline | 2 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/PlaybackService.kt | 36 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/EpisodeRow.kt | 113 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/MainActivity.kt | 11 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt | 2 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt | 9 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/QueueScreen.kt | 18 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/SettingsScreen.kt | 10 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt | 7 | ||||
| -rw-r--r-- | app/src/main/res/values-night/colors.xml | 4 | ||||
| -rw-r--r-- | app/src/main/res/values-night/themes.xml | 9 | ||||
| -rw-r--r-- | app/src/main/res/values/colors.xml | 4 | ||||
| -rw-r--r-- | app/src/main/res/values/themes.xml | 9 |
13 files changed, 150 insertions, 84 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 93f570e..09c9fdf 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -71.77 +71.31 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 26db0a3..b65e1b3 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt @@ -46,6 +46,10 @@ class PlaybackService : MediaSessionService() { /* handleAudioFocus = */ true, ) .setHandleAudioBecomingNoisy(true) + // These drive the notification, lock screen, Bluetooth remote and car controls. + // Asymmetric on purpose: back catches a missed sentence, forward steps over an ad. + .setSeekBackIncrementMs(SEEK_BACK_MS) + .setSeekForwardIncrementMs(SEEK_FORWARD_MS) .build() player = exo @@ -95,6 +99,8 @@ class PlaybackService : MediaSessionService() { private companion object { const val POSITION_TICK_MS = 2_000L + const val SEEK_BACK_MS = 15_000L + const val SEEK_FORWARD_MS = 25_000L } } @@ -106,18 +112,24 @@ private class ExoPlayhead(private val player: ExoPlayer) : Playhead { } /** Builds the Media3 item for an episode, preferring a downloaded file over the network. */ -fun EpisodeEntity.toMediaItem(): MediaItem = MediaItem.Builder() - // The episode id, so whatever comes back from the player can be tied to a row. - .setMediaId(id) - .setUri(localPath ?: mediaUrl) - .setMimeType(mediaType) - .setMediaMetadata( - androidx.media3.common.MediaMetadata.Builder() - .setTitle(title) - .setIsPlayable(true) - .build() - ) - .build() +fun EpisodeEntity.toMediaItem(showTitle: String? = null, author: String? = null): MediaItem = + MediaItem.Builder() + // The episode id, so whatever comes back from the player can be tied to a row. + .setMediaId(id) + .setUri(localPath ?: mediaUrl) + .setMimeType(mediaType) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + // A lock screen and a car head unit show artist and album, not just title. + // Left unset they render as blank lines under the episode name. + .setArtist(author ?: showTitle) + .setAlbumTitle(showTitle) + .setArtworkUri(imageUrl?.let(android.net.Uri::parse)) + .setIsPlayable(true) + .build() + ) + .build() /** Applies a show's speed, falling back to the global default. */ fun Player.applySpeed(showSpeed: Float?, defaultSpeed: Float) { 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 439f4cb..c5e10fe 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt @@ -1,66 +1,98 @@ 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.foundation.layout.size 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.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.PlaylistPlay +import androidx.compose.material.icons.outlined.CheckCircle 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.graphics.vector.ImageVector 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 +/** + * One episode and its actions. + * + * Icons rather than labelled buttons: five text buttons overflowed the row on a phone, and the + * last one collapsed into a one-letter-per-line column. Every icon still carries a + * contentDescription, so the row reads the same to a screen reader as it did with labels. + */ @Composable internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions) { - Column(Modifier.fillMaxWidth().padding(16.dp)) { + Column(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.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 = 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 = actions.download, enabled = episode.downloadState != DownloadState.DOWNLOADING) { - Text(if (episode.downloadState == DownloadState.DOWNLOADING) "…" else "Get") - } + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + ActionIcon( + icon = Icons.Default.PlayArrow, + // "Resume" and "Play" look identical as an icon, so the distinction moves into + // the label a screen reader reads out. + description = if (episode.positionMs > 0) "Resume" else "Play", + onClick = actions.play, + ) + ActionIcon(Icons.Default.PlaylistPlay, "Play next", actions.playNext) + ActionIcon(Icons.Default.PlaylistAdd, "Add to queue", actions.enqueue) + DownloadIcon(episode.downloadState, actions.download) + ActionIcon( + icon = if (episode.playedAtMs == null) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, + description = if (episode.playedAtMs == null) "Mark played" else "Mark unplayed", + onClick = actions.togglePlayed, + ) + } + } +} + +@Composable +private fun ActionIcon(icon: ImageVector, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick) { Icon(icon, contentDescription = description) } +} + +/** Download state is the icon itself, so the row doesn't need a separate progress line. */ +@Composable +private fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) { + when (state) { + DownloadState.DOWNLOADING -> + IconButton(onClick = {}, enabled = false) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } - Button(onClick = actions.togglePlayed) { - Text(if (episode.playedAtMs == null) "Played" else "Unplayed") + + DownloadState.DOWNLOADED -> + Icon( + Icons.Default.DownloadDone, + contentDescription = "Downloaded", + modifier = Modifier.padding(12.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + DownloadState.FAILED -> + IconButton(onClick = onDownload) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = "Download failed, tap to retry", + tint = MaterialTheme.colorScheme.error, + ) } - } + + else -> ActionIcon(Icons.Default.Download, "Download", onDownload) } } @@ -70,12 +102,7 @@ internal fun EpisodeEntity.progressLabel(): String { durationMs?.let { parts += formatDuration(it) } if (positionMs > 0) parts += "${formatDuration(positionMs)} in" if (playedAtMs != null) parts += "played" - when (downloadState) { - DownloadState.DOWNLOADED -> parts += "downloaded" - DownloadState.FAILED -> parts += "download failed" - DownloadState.DOWNLOADING -> parts += "downloading" - else -> Unit - } + if (downloadState == DownloadState.FAILED) parts += "download failed" return parts.joinToString(" · ") } 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 a395b0f..b4d291b 100644 --- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -24,9 +25,17 @@ class MainActivity : ComponentActivity() { } } +/** + * Dark only, deliberately -- not a dark default with a light option. + * + * This is a listening app used in the dark: in bed with a sleep timer running, or in a car. + * A light theme is the one nobody would pick, so it isn't built. + */ @Composable fun ListenTheme(content: @Composable () -> Unit) { - MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { content() } } + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { content() } + } } @Composable 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 54718a4..85b1508 100644 --- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt +++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt @@ -77,7 +77,7 @@ fun NowPlayingBar( ) } IconButton(onClick = onSkipForward) { - Icon(Icons.Default.FastForward, contentDescription = "Forward 30 seconds") + Icon(Icons.Default.FastForward, contentDescription = "Forward 25 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 index e112837..fa501a4 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -58,9 +58,10 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { /** 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 + val show = graph.subscriptions.getById(episode.subscriptionId) + val speed = show?.playbackSpeed ?: DEFAULT_SPEED connection.play( - item = episode.toMediaItem(), + item = episode.toMediaItem(showTitle = show?.title, author = show?.author), storedPositionMs = episode.positionMs, durationMs = episode.durationMs, speed = speed, @@ -136,8 +137,10 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) { const val DEFAULT_SPEED = 1.0f // 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 = 30_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 fbeb7d8..4294168 100644 --- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt @@ -10,8 +10,10 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -51,7 +53,11 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit topBar = { TopAppBar( title = { Text("Up Next") }, - navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, ) }, ) { padding -> @@ -65,8 +71,12 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, 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") } + IconButton(onClick = { player.play(episode) }) { + Icon(Icons.Default.PlayArrow, contentDescription = "Play") + } + IconButton(onClick = { vm.removeFromQueue(episode.id) }) { + Icon(Icons.Default.RemoveCircleOutline, contentDescription = "Remove from queue") + } } 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 index bdb1cef..127f5db 100644 --- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt @@ -10,8 +10,12 @@ 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.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.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -52,7 +56,11 @@ fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> U topBar = { TopAppBar( title = { Text("Settings") }, - navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, ) }, ) { padding -> 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 483333d..f15debd 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt @@ -10,6 +10,7 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button @@ -58,7 +59,11 @@ fun SubscriptionScreen( topBar = { TopAppBar( title = { Text(subscription?.title ?: "Podcast", maxLines = 1, overflow = TextOverflow.Ellipsis) }, - navigationIcon = { Button(onClick = onBack) { Text("Back") } }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, ) }, ) { padding -> diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 4b2fc81..0000000 --- a/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <color name="window_background">#121618</color> -</resources> diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml deleted file mode 100644 index 9a93256..0000000 --- a/app/src/main/res/values-night/themes.xml +++ /dev/null @@ -1,9 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <style name="Theme.Listen" parent="android:Theme.Material.NoActionBar"> - <item name="android:windowBackground">@color/window_background</item> - <item name="android:statusBarColor">@color/window_background</item> - <item name="android:navigationBarColor">@color/window_background</item> - <item name="android:windowLightStatusBar">false</item> - </style> -</resources> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 5118730..6440c3f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <color name="window_background">#EDEFEC</color> + <!-- Listen is dark-only (see ListenTheme), so the light-qualified resources carry the dark + values too. Without this the window flashes white before Compose draws its first frame. --> + <color name="window_background">#121618</color> <color name="icon_background">#1F5352</color> </resources> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 816a5ab..5cb8974 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,11 +4,14 @@ variant. FeeDreader shipped a theme parent that silently required API 29 against a minSdk of 26 and only an audit caught it; picking a parent well below minSdk removes that class of mistake. Compose draws the whole UI, so this theme only needs to supply - a window background and get out of the way. Dark mode comes from values-night. --> - <style name="Theme.Listen" parent="android:Theme.Material.Light.NoActionBar"> + a window background and get out of the way. + + Dark parent even in the unqualified resources: the app is dark-only, and using the + light parent here produces a white flash on cold start. --> + <style name="Theme.Listen" parent="android:Theme.Material.NoActionBar"> <item name="android:windowBackground">@color/window_background</item> <item name="android:statusBarColor">@color/window_background</item> <item name="android:navigationBarColor">@color/window_background</item> - <item name="android:windowLightStatusBar">true</item> + <item name="android:windowLightStatusBar">false</item> </style> </resources> |
