diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-21 12:00:25 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-23 22:50:55 -1000 |
| commit | 84ec886721f9f6052447a19e84d6c8e8d9f62ae6 (patch) | |
| tree | aea029473ab9a0fc70deb92a61b0f3f4007798b1 | |
| parent | af92823efb95cd8b29a81f244916e56d70b02150 (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
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() + } } |
