summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 19:03:24 +0000
committerClaude <noreply@anthropic.com>2026-09-21 19:03:24 +0000
commit42fce8b0293f78e8cf9f60a0bf2c4cd1bd010a5a (patch)
tree1701398ee2cb40e81a1751a2ab0f5339c7f907df /app
parent2ce4d50eb8b17c751d347865473f9895edecd938 (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
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt36
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt113
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt11
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt2
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/QueueScreen.kt18
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt7
-rw-r--r--app/src/main/res/values-night/colors.xml4
-rw-r--r--app/src/main/res/values-night/themes.xml9
-rw-r--r--app/src/main/res/values/colors.xml4
-rw-r--r--app/src/main/res/values/themes.xml9
12 files changed, 149 insertions, 83 deletions
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>