From 42fce8b0293f78e8cf9f60a0bf2c4cd1bd010a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 19:03:24 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../org/terst/listen/playback/PlaybackService.kt | 36 ++++--- .../main/java/org/terst/listen/ui/EpisodeRow.kt | 113 +++++++++++++-------- .../main/java/org/terst/listen/ui/MainActivity.kt | 11 +- .../main/java/org/terst/listen/ui/NowPlayingBar.kt | 2 +- .../java/org/terst/listen/ui/PlayerViewModel.kt | 9 +- .../main/java/org/terst/listen/ui/QueueScreen.kt | 18 +++- .../java/org/terst/listen/ui/SettingsScreen.kt | 10 +- .../java/org/terst/listen/ui/SubscriptionScreen.kt | 7 +- app/src/main/res/values-night/colors.xml | 4 - app/src/main/res/values-night/themes.xml | 9 -- app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/themes.xml | 9 +- 12 files changed, 149 insertions(+), 83 deletions(-) delete mode 100644 app/src/main/res/values-night/colors.xml delete mode 100644 app/src/main/res/values-night/themes.xml (limited to 'app') 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 @@ - - - #121618 - 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 @@ - - - - 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 @@ - #EDEFEC + + #121618 #1F5352 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. --> - -- cgit v1.2.3