diff options
Diffstat (limited to 'app/src')
| -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 |
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> |
