summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-21 12:00:25 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-09-23 22:50:55 -1000
commit84ec886721f9f6052447a19e84d6c8e8d9f62ae6 (patch)
treeaea029473ab9a0fc70deb92a61b0f3f4007798b1
parentaf92823efb95cd8b29a81f244916e56d70b02150 (diff)
Add artwork, a real player screen, and queue drag-reorder
Closes three of the four Phase 1 gaps in CLAUDE.md's "Known gaps": artwork was parsed and passed to the lock screen but never rendered, playback had a transport bar with no scrubbing or per-show speed, and QueueOrdering.move was tested but unreachable from any screen. Android Auto real-car verification stays deferred, per Peter's call. - Artwork: new Artwork.preferredUrl (episode image falling back to the show's), resolved once at EpisodeEntity.toMediaItem's MediaMetadata boundary so the lock screen, Android Auto, the now-playing bar and the new player screen all read it from one place. Coil3 wired to the app's own OkHttpClient. Pinned to 3.3.0: later releases require a newer Kotlin stdlib or Compose than this project runs (Compose 1.12 needs compileSdk 37, one above what AGP 8.13.2 supports). - Player screen: scrubbing (PlayerConnection.seekTo, committed on drag release) and per-show speed (PlayerViewModel.setShowSpeed persists SubscriptionEntity.playbackSpeed via a new SubscriptionDao.updateSpeed) -- the field existed since Phase 1 with nothing to reach it. Opened by tapping the now-playing bar's title/artwork; Up Next moved to its own icon there. - Queue reorder: drag-and-drop via sh.calvin.reorderable, backed by a new QueueRepository.reorder that writes the final order once on drag release rather than per drag step (the gesture reports intermediate positions continuously). Coverage baseline drop (62.10% -> 61.15%) is entirely in already-0%-covered Media3 glue -- see COVERAGE_EXCEPTION. The new pure logic (Artwork, QueueRepository.reorder) is fully covered by new tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
-rw-r--r--.coverage-baseline2
-rw-r--r--app/build.gradle.kts12
-rw-r--r--app/src/main/java/org/terst/listen/ListenApp.kt9
-rw-r--r--app/src/main/java/org/terst/listen/data/Artwork.kt7
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt3
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt18
-rw-r--r--app/src/main/java/org/terst/listen/playback/LibraryTree.kt5
-rw-r--r--app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt21
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt12
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt11
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt54
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryNav.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt66
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt12
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt35
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerScreen.kt161
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt23
-rw-r--r--app/src/main/java/org/terst/listen/ui/QueueScreen.kt103
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt29
-rw-r--r--app/src/test/java/org/terst/listen/data/ArtworkTest.kt30
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt4
-rw-r--r--app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt12
24 files changed, 545 insertions, 107 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 446e069..4f2ca7b 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-62.10
+61.15
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b8bfb12..f680a08 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -84,6 +84,18 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
+ // Artwork loading. Wired to the app's own OkHttpClient (see AppGraph.imageLoader)
+ // rather than Coil's default, so image fetches share connection pooling and timeouts
+ // with the rest of the app. Pinned to 3.3.0: 3.4.0+ requires a newer Kotlin stdlib than
+ // this project's 2.2.21, and 3.6.0+ pulls Compose 1.12, which needs compileSdk 37 --
+ // one above the max AGP 8.13.2 supports.
+ val coil = "3.3.0"
+ implementation("io.coil-kt.coil3:coil-compose:$coil")
+ implementation("io.coil-kt.coil3:coil-network-okhttp:$coil")
+
+ // Drag-to-reorder for the queue screen, backing QueueOrdering.move.
+ implementation("sh.calvin.reorderable:reorderable:3.1.0")
+
// Media3 is the whole playback stack: ExoPlayer for decode and speed control,
// MediaSessionService for lock-screen and Bluetooth transport, and its DownloadManager,
// which shares a cache with playback so a partly-downloaded episode streams the remainder
diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt
index 23032fd..66aada9 100644
--- a/app/src/main/java/org/terst/listen/ListenApp.kt
+++ b/app/src/main/java/org/terst/listen/ListenApp.kt
@@ -1,13 +1,20 @@
package org.terst.listen
import android.app.Application
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import org.terst.listen.data.Graph
import org.terst.listen.work.Scheduling
-class ListenApp : Application() {
+class ListenApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// Scheduled on every launch but enqueued with KEEP, so an existing schedule is left
// alone rather than having its interval reset each time the app opens.
Scheduling.schedule(this)
}
+
+ // Routes every AsyncImage through the app's own OkHttpClient -- see AppGraph.imageLoader.
+ override fun newImageLoader(context: PlatformContext): ImageLoader = Graph.get(this).imageLoader
}
diff --git a/app/src/main/java/org/terst/listen/data/Artwork.kt b/app/src/main/java/org/terst/listen/data/Artwork.kt
new file mode 100644
index 0000000..85585fe
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/Artwork.kt
@@ -0,0 +1,7 @@
+package org.terst.listen.data
+
+/** Which image to show: the episode's own, falling back to the show's. */
+object Artwork {
+ fun preferredUrl(episodeImageUrl: String?, subscriptionImageUrl: String?): String? =
+ episodeImageUrl ?: subscriptionImageUrl
+}
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index deb350a..2892d9d 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -41,6 +41,9 @@ interface SubscriptionDao {
WHERE id = :id"""
)
suspend fun recordSuccess(id: String, atMs: Long, etag: String?, lastModified: String?)
+
+ @Query("UPDATE subscriptions SET playbackSpeed = :speed WHERE id = :id")
+ suspend fun updateSpeed(id: String, speed: Float)
}
@Dao
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index 60d1590..94c8fdf 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -1,6 +1,8 @@
package org.terst.listen.data
import android.content.Context
+import coil3.ImageLoader
+import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import okhttp3.OkHttpClient
import org.terst.listen.download.ChaquopyYouTubeSource
import org.terst.listen.download.EpisodeDownloader
@@ -61,6 +63,14 @@ class AppGraph(private val context: Context) {
val queue: QueueRepository by lazy { QueueRepository(queueDao) }
+ // Reuses the app's own OkHttpClient rather than Coil's default, so artwork fetches share
+ // connection pooling and timeouts with feed refreshes and downloads.
+ val imageLoader: ImageLoader by lazy {
+ ImageLoader.Builder(context)
+ .components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) }
+ .build()
+ }
+
private companion object {
const val CONNECT_TIMEOUT_SECONDS = 30L
const val READ_TIMEOUT_SECONDS = 60L
@@ -83,6 +93,14 @@ class QueueRepository(private val dao: QueueDao) {
suspend fun move(from: Int, to: Int) = rewrite { QueueOrdering.move(it, from, to) }
+ /**
+ * Replaces the whole order at once.
+ *
+ * Separate from [move]: a drag gesture reports intermediate positions continuously, and
+ * only the final order should ever reach storage -- one write, not one per drag step.
+ */
+ suspend fun reorder(episodeIds: List<String>) = dao.replaceAll(episodeIds)
+
/** Drops the finished episode and reports what plays next. */
suspend fun advance(): String? {
val current = ids()
diff --git a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
index 3c9f8f4..3c12f1d 100644
--- a/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
+++ b/app/src/main/java/org/terst/listen/playback/LibraryTree.kt
@@ -2,6 +2,7 @@ package org.terst.listen.playback
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
+import org.terst.listen.data.Artwork
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
@@ -45,7 +46,7 @@ object LibraryTree {
* The media id carries the episode id rather than the URL: the tree is rebuilt on every
* browse, and an id survives a publisher re-hosting the file where a URL would not.
*/
- fun episode(entity: EpisodeEntity, showTitle: String? = null): MediaItem =
+ fun episode(entity: EpisodeEntity, showTitle: String? = null, showImageUrl: String? = null): MediaItem =
MediaItem.Builder()
.setMediaId(EPISODE_PREFIX + entity.id)
.setMediaMetadata(
@@ -53,7 +54,7 @@ object LibraryTree {
.setTitle(entity.title)
.setArtist(showTitle)
.setAlbumTitle(showTitle)
- .setArtworkUri(entity.imageUrl?.let(android.net.Uri::parse))
+ .setArtworkUri(Artwork.preferredUrl(entity.imageUrl, showImageUrl)?.let(android.net.Uri::parse))
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_PODCAST_EPISODE)
diff --git a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
index 9ca9fa3..087d457 100644
--- a/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
+++ b/app/src/main/java/org/terst/listen/playback/ListenSessionCallback.kt
@@ -99,7 +99,8 @@ class ListenSessionCallback(
parentId == LibraryTree.QUEUE ->
graph.queueDao.getEntries().mapNotNull { entry ->
graph.episodes.getById(entry.episodeId)?.let { episode ->
- LibraryTree.episode(episode, showTitleOf(episode.subscriptionId))
+ val show = graph.subscriptions.getById(episode.subscriptionId)
+ LibraryTree.episode(episode, show?.title, show?.imageUrl)
}
}
@@ -108,13 +109,13 @@ class ListenSessionCallback(
LibraryTree.subscriptionId(parentId) != null -> {
val id = LibraryTree.subscriptionId(parentId)!!
- val showTitle = showTitleOf(id)
+ val show = graph.subscriptions.getById(id)
// Newest first, and capped: a feed here held 2753 episodes, and neither a car
// screen nor the browser protocol wants that list.
graph.episodes.getForSubscription(id)
.sortedByDescending { it.publishedAtMs ?: 0 }
.take(MAX_EPISODES)
- .map { LibraryTree.episode(it, showTitle) }
+ .map { LibraryTree.episode(it, show?.title, show?.imageUrl) }
}
else -> emptyList()
@@ -132,7 +133,8 @@ class ListenSessionCallback(
if (episode == null) {
LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)
} else {
- LibraryResult.ofItem(LibraryTree.episode(episode, showTitleOf(episode.subscriptionId)), null)
+ val show = graph.subscriptions.getById(episode.subscriptionId)
+ LibraryResult.ofItem(LibraryTree.episode(episode, show?.title, show?.imageUrl), null)
}
}
@@ -150,9 +152,11 @@ class ListenSessionCallback(
mediaItems.mapNotNull { item ->
val episodeId = LibraryTree.episodeId(item.mediaId) ?: return@mapNotNull item
val episode = graph.episodes.getById(episodeId) ?: return@mapNotNull null
+ val show = graph.subscriptions.getById(episode.subscriptionId)
episode.toMediaItem(
- showTitle = showTitleOf(episode.subscriptionId),
- author = graph.subscriptions.getById(episode.subscriptionId)?.author,
+ showTitle = show?.title,
+ author = show?.author,
+ showImageUrl = show?.imageUrl,
)
}.toMutableList()
}
@@ -203,15 +207,12 @@ class ListenSessionCallback(
.filter { it.title.lowercase().contains(needle) }
.sortedByDescending { it.publishedAtMs ?: 0 }
.take(MAX_EPISODE_HITS_PER_SHOW)
- .map { LibraryTree.episode(it, show.title) }
+ .map { LibraryTree.episode(it, show.title, show.imageUrl) }
}
return (showHits.map(LibraryTree::subscription) + episodeHits).take(MAX_SEARCH_RESULTS)
}
- private suspend fun showTitleOf(subscriptionId: String): String? =
- graph.subscriptions.getById(subscriptionId)?.title
-
private companion object {
const val MAX_EPISODES = 100
const val MAX_EPISODE_HITS_PER_SHOW = 5
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 5746286..45d6e8c 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.terst.listen.data.AppDatabase
+import org.terst.listen.data.Artwork
import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
@@ -118,7 +119,11 @@ 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(showTitle: String? = null, author: String? = null): MediaItem =
+fun EpisodeEntity.toMediaItem(
+ showTitle: String? = null,
+ author: String? = null,
+ showImageUrl: String? = null,
+): MediaItem =
MediaItem.Builder()
// The episode id, so whatever comes back from the player can be tied to a row.
.setMediaId(id)
@@ -131,7 +136,10 @@ fun EpisodeEntity.toMediaItem(showTitle: String? = null, author: String? = null)
// Left unset they render as blank lines under the episode name.
.setArtist(author ?: showTitle)
.setAlbumTitle(showTitle)
- .setArtworkUri(imageUrl?.let(android.net.Uri::parse))
+ // Many feeds only set the image at channel level, not per episode, so the
+ // show's image is the fallback here -- and the single point everything
+ // downstream (lock screen, Auto, the now-playing bar) reads artwork from.
+ .setArtworkUri(Artwork.preferredUrl(imageUrl, showImageUrl)?.let(android.net.Uri::parse))
.setIsPlayable(true)
.build()
)
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
index df756ca..b3644d3 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -19,6 +19,7 @@ data class NowPlaying(
val positionMs: Long,
val durationMs: Long?,
val speed: Float,
+ val artworkUrl: String? = null,
)
/**
@@ -117,6 +118,13 @@ class PlayerConnection(private val context: Context) {
publish()
}
+ /** Used by the player screen's scrubber, committed once the drag ends. */
+ fun seekTo(positionMs: Long) {
+ val player = controller ?: return
+ player.seekTo(positionMs.coerceAtLeast(0))
+ publish()
+ }
+
fun pause() {
controller?.pause()
publish()
@@ -142,6 +150,9 @@ class PlayerConnection(private val context: Context) {
positionMs = player?.currentPosition ?: 0,
durationMs = player?.duration?.takeIf { it > 0 },
speed = player?.playbackParameters?.speed ?: 1.0f,
+ // Already resolved (episode falling back to the show) at the point the MediaItem
+ // was built -- see EpisodeEntity.toMediaItem -- so nothing to fall back to here.
+ artworkUrl = player?.currentMediaItem?.mediaMetadata?.artworkUri?.toString(),
)
}
}
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 c5e10fe..e9148c4 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -1,11 +1,13 @@
package org.terst.listen.ui
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
@@ -22,8 +24,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import java.util.concurrent.TimeUnit
@@ -36,27 +40,37 @@ import java.util.concurrent.TimeUnit
* 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(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)
+internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artworkUrl: String? = null) {
+ Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) {
+ AsyncImage(
+ model = artworkUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Column(Modifier.weight(1f).padding(start = 12.dp)) {
+ Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2)
+ Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
- 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,
- )
+ 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,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryNav.kt b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt
new file mode 100644
index 0000000..d7a636f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/LibraryNav.kt
@@ -0,0 +1,9 @@
+package org.terst.listen.ui
+
+/** Where the library screen's actions navigate to, bundled so the signature stays readable. */
+data class LibraryNav(
+ val openSubscription: (String) -> Unit,
+ val openPlayer: () -> Unit,
+ val openQueue: () -> Unit,
+ val openSettings: () -> Unit,
+)
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index 80631c9..16301aa 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -1,5 +1,6 @@
package org.terst.listen.ui
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -7,8 +8,10 @@ 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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
@@ -37,8 +40,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
@@ -46,13 +51,7 @@ import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun LibraryScreen(
- vm: ListenViewModel,
- player: PlayerViewModel,
- onOpenSubscription: (String) -> Unit,
- onOpenQueue: () -> Unit,
- onOpenSettings: () -> Unit,
-) {
+fun LibraryScreen(vm: ListenViewModel, player: PlayerViewModel, nav: LibraryNav) {
val subscriptions by vm.subscriptions.collectAsState()
val queue by vm.queue.collectAsState()
val refreshing by vm.refreshing.collectAsState()
@@ -79,7 +78,7 @@ fun LibraryScreen(
IconButton(onClick = vm::refreshAll, enabled = !refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds")
}
- IconButton(onClick = onOpenSettings) {
+ IconButton(onClick = nav.openSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
},
@@ -89,10 +88,13 @@ fun LibraryScreen(
bottomBar = {
NowPlayingBar(
state = nowPlaying,
- onTogglePlayPause = player::togglePlayPause,
- onSkipBack = player::skipBack,
- onSkipForward = player::skipForward,
- onOpenQueue = onOpenQueue,
+ actions = NowPlayingActions(
+ togglePlayPause = player::togglePlayPause,
+ skipBack = player::skipBack,
+ skipForward = player::skipForward,
+ openPlayer = nav.openPlayer,
+ openQueue = nav.openQueue,
+ ),
)
},
) { padding ->
@@ -104,7 +106,7 @@ fun LibraryScreen(
AddSubscription(onSubmit = { vm.subscribe(it); adding = false })
}
- LibraryList(subscriptions, queue, onOpenSubscription, onOpenQueue)
+ LibraryList(subscriptions, queue, nav.openSubscription, nav.openQueue)
}
}
}
@@ -177,20 +179,30 @@ private fun AddSubscription(onSubmit: (String) -> Unit) {
@Composable
private fun SubscriptionRow(subscription: SubscriptionEntity, onClick: () -> Unit) {
- Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) {
- Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
- subscription.author?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1)
- }
- // A feed that has quietly stopped working is otherwise indistinguishable from one with
- // nothing new, which is the whole reason the error is stored rather than logged.
- subscription.lastError?.let {
- Text(
- "Last refresh failed: $it",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- maxLines = 2,
- )
+ Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) {
+ AsyncImage(
+ model = subscription.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Column(Modifier.weight(1f).padding(start = 12.dp)) {
+ Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
+ subscription.author?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1)
+ }
+ // A feed that has quietly stopped working is otherwise indistinguishable from one
+ // with nothing new, which is the whole reason the error is stored rather than logged.
+ subscription.lastError?.let {
+ Text(
+ "Last refresh failed: $it",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 2,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 5d483d6..263eee0 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -171,6 +171,10 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { graph.queue.remove(episodeId) }
}
+ fun reorderQueue(episodeIds: List<String>) {
+ viewModelScope.launch { graph.queue.reorder(episodeIds) }
+ }
+
fun download(episode: EpisodeEntity) {
viewModelScope.launch {
val subscription = graph.subscriptions.getById(episode.subscriptionId)
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 b4d291b..431343c 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -47,11 +47,17 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
LibraryScreen(
vm = viewModel,
player = player,
- onOpenSubscription = { nav.navigate("subscription/$it") },
- onOpenQueue = { nav.navigate("queue") },
- onOpenSettings = { nav.navigate("settings") },
+ nav = LibraryNav(
+ openSubscription = { nav.navigate("subscription/$it") },
+ openPlayer = { nav.navigate("player") },
+ openQueue = { nav.navigate("queue") },
+ openSettings = { nav.navigate("settings") },
+ ),
)
}
+ composable("player") {
+ PlayerScreen(player = player, onBack = { nav.popBackStack() })
+ }
composable("settings") {
SettingsScreen(vm = viewModel,
player = player, onBack = { nav.popBackStack() })
diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt
new file mode 100644
index 0000000..f3deb3f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingActions.kt
@@ -0,0 +1,10 @@
+package org.terst.listen.ui
+
+/** The now-playing bar's actions, bundled so the signature stays readable. */
+data class NowPlayingActions(
+ val togglePlayPause: () -> Unit,
+ val skipBack: () -> Unit,
+ val skipForward: () -> Unit,
+ val openPlayer: () -> Unit,
+ val openQueue: () -> Unit,
+)
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 85b1508..4031188 100644
--- a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
@@ -1,12 +1,16 @@
package org.terst.listen.ui
+import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.PlayArrow
@@ -20,8 +24,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import org.terst.listen.playback.NowPlaying
/**
@@ -31,13 +37,7 @@ import org.terst.listen.playback.NowPlaying
* strip at the bottom of every screen is worse than no strip.
*/
@Composable
-fun NowPlayingBar(
- state: NowPlaying,
- onTogglePlayPause: () -> Unit,
- onSkipBack: () -> Unit,
- onSkipForward: () -> Unit,
- onOpenQueue: () -> Unit,
-) {
+fun NowPlayingBar(state: NowPlaying, actions: NowPlayingActions) {
if (state.episodeId == null) return
Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) {
@@ -54,7 +54,17 @@ fun NowPlayingBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
- Column(Modifier.weight(1f).clickable(onClick = onOpenQueue).padding(8.dp)) {
+ AsyncImage(
+ model = state.artworkUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(onClick = actions.openPlayer),
+ )
+
+ Column(Modifier.weight(1f).clickable(onClick = actions.openPlayer).padding(8.dp)) {
Text(
state.title ?: "Playing",
style = MaterialTheme.typography.bodyMedium,
@@ -67,18 +77,21 @@ fun NowPlayingBar(
)
}
- IconButton(onClick = onSkipBack) {
+ IconButton(onClick = actions.skipBack) {
Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
}
- IconButton(onClick = onTogglePlayPause) {
+ IconButton(onClick = actions.togglePlayPause) {
Icon(
if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Play",
)
}
- IconButton(onClick = onSkipForward) {
+ IconButton(onClick = actions.skipForward) {
Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds")
}
+ IconButton(onClick = actions.openQueue) {
+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = "Up Next")
+ }
}
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
new file mode 100644
index 0000000..1fe2f67
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/PlayerScreen.kt
@@ -0,0 +1,161 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+
+/** Offered speeds. Beyond 2x speech becomes hard to follow, so the list stops there. */
+private val SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PlayerScreen(player: PlayerViewModel, onBack: () -> Unit) {
+ val nowPlaying by player.nowPlaying.collectAsState()
+ val show by player.currentShow.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(show?.title ?: "Now Playing", maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding).fillMaxSize().padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ AsyncImage(
+ model = nowPlaying.artworkUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+
+ Text(
+ nowPlaying.title ?: "Nothing playing",
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Scrubber(
+ positionMs = nowPlaying.positionMs,
+ durationMs = nowPlaying.durationMs,
+ onSeek = player::seekTo,
+ )
+
+ TransportRow(isPlaying = nowPlaying.isPlaying, player = player)
+
+ if (show != null) {
+ SpeedRow(current = show?.playbackSpeed ?: nowPlaying.speed, onSelect = player::setShowSpeed)
+ }
+ }
+ }
+}
+
+@Composable
+private fun TransportRow(isPlaying: Boolean, player: PlayerViewModel) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = player::skipBack) {
+ Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
+ }
+ IconButton(onClick = player::togglePlayPause) {
+ Icon(
+ if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (isPlaying) "Pause" else "Play",
+ modifier = Modifier.padding(12.dp),
+ )
+ }
+ IconButton(onClick = player::skipForward) {
+ Icon(Icons.Default.FastForward, contentDescription = "Forward 25 seconds")
+ }
+ }
+}
+
+/**
+ * A dragging finger shouldn't fight the live position tick, so the local value only tracks the
+ * drag while one is in progress; the seek itself only fires once the drag ends.
+ */
+@Composable
+private fun Scrubber(positionMs: Long, durationMs: Long?, onSeek: (Long) -> Unit) {
+ val duration = durationMs?.takeIf { it > 0 } ?: 0L
+ var dragValue by remember { mutableFloatStateOf(-1f) }
+ val shown = if (dragValue >= 0f) dragValue else positionMs.toFloat()
+
+ Column(Modifier.fillMaxWidth()) {
+ Slider(
+ value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
+ onValueChange = { dragValue = it },
+ onValueChangeFinished = {
+ onSeek(dragValue.toLong())
+ dragValue = -1f
+ },
+ valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
+ enabled = duration > 0,
+ )
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(formatClock(shown.toLong()), style = MaterialTheme.typography.bodySmall)
+ Text(formatClock(duration), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+}
+
+@Composable
+private fun SpeedRow(current: Float, onSelect: (Float) -> Unit) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ SPEEDS.forEach { speed ->
+ if (current == speed) {
+ Button(onClick = { onSelect(speed) }) { Text("${speed}×") }
+ } else {
+ OutlinedButton(onClick = { onSelect(speed) }) { Text("${speed}×") }
+ }
+ }
+ }
+}
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 aef40ac..50bbfb7 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
+import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.playback.NowPlaying
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.SleepTimer
@@ -40,6 +41,10 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
}
val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying
+ /** The show behind whatever is currently playing, for the player screen's speed control. */
+ private val _currentShow = MutableStateFlow<SubscriptionEntity?>(null)
+ val currentShow: StateFlow<SubscriptionEntity?> = _currentShow.asStateFlow()
+
init {
// Position advances with no callback of its own, so the bar is driven by a tick.
viewModelScope.launch {
@@ -64,9 +69,10 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
graph.queue.playNow(episode.id)
val show = graph.subscriptions.getById(episode.subscriptionId)
+ _currentShow.value = show
val speed = show?.playbackSpeed ?: DEFAULT_SPEED
connection.play(
- item = episode.toMediaItem(showTitle = show?.title, author = show?.author),
+ item = episode.toMediaItem(showTitle = show?.title, author = show?.author, showImageUrl = show?.imageUrl),
storedPositionMs = episode.positionMs,
durationMs = episode.durationMs,
speed = speed,
@@ -97,6 +103,21 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
fun setSpeed(speed: Float) = connection.setSpeed(speed)
+ /** Player screen's scrubber, committed once the drag ends rather than on every move. */
+ fun seekTo(positionMs: Long) = connection.seekTo(positionMs)
+
+ /**
+ * Sets the speed for whatever show is currently playing, persisted so it survives to the
+ * next episode from that show. This is what makes [SubscriptionEntity.playbackSpeed]
+ * reachable -- until the player screen, only the live, unpersisted [setSpeed] existed.
+ */
+ fun setShowSpeed(speed: Float) {
+ val show = _currentShow.value ?: return
+ viewModelScope.launch { graph.subscriptions.updateSpeed(show.id, speed) }
+ _currentShow.value = show.copy(playbackSpeed = speed)
+ connection.setSpeed(speed)
+ }
+
// --- sleep timer ---------------------------------------------------------------------
private val sleepTimer = SleepTimer()
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 4294168..cea4630 100644
--- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
@@ -1,32 +1,27 @@
package org.terst.listen.ui
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.background
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
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.DragHandle
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
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
@@ -38,17 +33,35 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import org.terst.listen.data.DownloadState
+import coil3.compose.AsyncImage
+import org.terst.listen.data.Artwork
import org.terst.listen.data.EpisodeEntity
-import org.terst.listen.data.SubscriptionEntity
-import java.util.concurrent.TimeUnit
+import sh.calvin.reorderable.ReorderableCollectionItemScope
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) {
val queue by vm.queue.collectAsState()
+ val subscriptions by vm.subscriptions.collectAsState()
+ val subscriptionImages = remember(subscriptions) {
+ subscriptions.associate { it.id to it.imageUrl }
+ }
+
+ // Drives the drag animation locally; only the final order (on drag release) is persisted,
+ // since the gesture reports intermediate positions continuously.
+ var localOrder by remember { mutableStateOf(queue) }
+ LaunchedEffect(queue) { localOrder = queue }
+
+ val lazyListState = rememberLazyListState()
+ val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
+ localOrder = localOrder.toMutableList().apply { add(to.index, removeAt(from.index)) }
+ }
+
Scaffold(
topBar = {
TopAppBar(
@@ -61,22 +74,16 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit
)
},
) { padding ->
- LazyColumn(Modifier.padding(padding).fillMaxSize()) {
- items(queue, key = { it.id }) { episode ->
- Row(
- Modifier.fillMaxWidth().padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Text(episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
- Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
- }
- 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")
- }
+ LazyColumn(state = lazyListState, modifier = Modifier.padding(padding).fillMaxSize()) {
+ items(localOrder, key = { it.id }) { episode ->
+ ReorderableItem(reorderableState, key = episode.id) {
+ QueueRow(
+ episode = episode,
+ artworkUrl = Artwork.preferredUrl(episode.imageUrl, subscriptionImages[episode.subscriptionId]),
+ onPlay = { player.play(episode) },
+ onRemove = { vm.removeFromQueue(episode.id) },
+ onDragStopped = { vm.reorderQueue(localOrder.map { it.id }) },
+ )
}
HorizontalDivider()
}
@@ -84,3 +91,41 @@ fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit
}
}
+@Composable
+private fun ReorderableCollectionItemScope.QueueRow(
+ episode: EpisodeEntity,
+ artworkUrl: String?,
+ onPlay: () -> Unit,
+ onRemove: () -> Unit,
+ onDragStopped: () -> Unit,
+) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ modifier = Modifier.draggableHandle(onDragStopped = onDragStopped),
+ onClick = {},
+ ) {
+ Icon(Icons.Default.DragHandle, contentDescription = "Reorder")
+ }
+ AsyncImage(
+ model = artworkUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(6.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Column(Modifier.weight(1f).padding(start = 12.dp)) {
+ Text(episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
+ }
+ IconButton(onClick = onPlay) {
+ Icon(Icons.Default.PlayArrow, contentDescription = "Play")
+ }
+ IconButton(onClick = onRemove) {
+ Icon(Icons.Default.RemoveCircleOutline, contentDescription = "Remove from queue")
+ }
+ }
+}
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 70f48e5..fbcc2db 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -1,5 +1,6 @@
package org.terst.listen.ui
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -7,8 +8,10 @@ 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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
@@ -40,8 +43,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import org.terst.listen.data.Artwork
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.SubscriptionEntity
@@ -90,9 +96,11 @@ fun SubscriptionScreen(
)
LazyColumn(Modifier.padding(padding).fillMaxSize()) {
+ item { SubscriptionHeader(subscription) }
items(episodes, key = { it.id }) { episode ->
EpisodeRow(
episode = episode,
+ artworkUrl = Artwork.preferredUrl(episode.imageUrl, subscription?.imageUrl),
actions = EpisodeActions(
play = { player.play(episode) },
enqueue = { vm.enqueue(episode.id) },
@@ -109,6 +117,27 @@ fun SubscriptionScreen(
}
}
+@Composable
+private fun SubscriptionHeader(subscription: SubscriptionEntity?) {
+ if (subscription == null) return
+ Row(Modifier.fillMaxWidth().padding(16.dp)) {
+ AsyncImage(
+ model = subscription.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Column(Modifier.padding(start = 12.dp)) {
+ Text(subscription.title, style = MaterialTheme.typography.titleMedium, maxLines = 2)
+ subscription.author?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, maxLines = 1)
+ }
+ }
+ }
+}
+
/** Confirmed, because unsubscribing cascades: episodes, downloads and every playback position
* for that show go with it, and none of it is recoverable. */
@Composable
diff --git a/app/src/test/java/org/terst/listen/data/ArtworkTest.kt b/app/src/test/java/org/terst/listen/data/ArtworkTest.kt
new file mode 100644
index 0000000..be24871
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/ArtworkTest.kt
@@ -0,0 +1,30 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** Which image to show: the episode's own, falling back to the show's. */
+class ArtworkTest {
+
+ @Test
+ fun `episode image wins when present`() {
+ assertEquals(
+ "episode.jpg",
+ Artwork.preferredUrl("episode.jpg", "subscription.jpg"),
+ )
+ }
+
+ @Test
+ fun `falls back to the subscription image when the episode has none`() {
+ assertEquals(
+ "subscription.jpg",
+ Artwork.preferredUrl(null, "subscription.jpg"),
+ )
+ }
+
+ @Test
+ fun `null when neither has an image`() {
+ assertNull(Artwork.preferredUrl(null, null))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index f313687..1822ab9 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -40,6 +40,10 @@ class FakeSubscriptionDao : SubscriptionDao {
replace(id) { it.copy(lastError = null, lastFetchedAtMs = atMs, etag = etag, lastModified = lastModified) }
}
+ override suspend fun updateSpeed(id: String, speed: Float) {
+ replace(id) { it.copy(playbackSpeed = speed) }
+ }
+
private fun replace(id: String, transform: (SubscriptionEntity) -> SubscriptionEntity) {
val index = rows.indexOfFirst { it.id == id }
if (index >= 0) rows[index] = transform(rows[index])
diff --git a/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
index dde5bdb..d880e57 100644
--- a/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
@@ -141,4 +141,16 @@ class QueueRepositoryTest {
assertEquals(listOf("a", "b"), ids())
assertContiguous()
}
+
+ /**
+ * Backs the drag-to-reorder queue screen: the gesture reports intermediate positions
+ * continuously, so only the final order should ever reach storage.
+ */
+ @Test
+ fun `reorder applies the given order and stays contiguous`() = runTest {
+ listOf("a", "b", "c").forEach { repo.append(it) }
+ repo.reorder(listOf("c", "a", "b"))
+ assertEquals(listOf("c", "a", "b"), ids())
+ assertContiguous()
+ }
}