summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
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>