From bb8ef5231e52d6d6a289f4cb257d627fc2117a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 12:12:45 +0100 Subject: [PATCH 1/5] fix(auth): bind the OAuth loopback listener by address, not by name - resolving "localhost" went through the system resolver, so a phone with a broken Private DNS or a network blocking plain DNS failed the sign-in before the browser even opened, reported as a DNS error - the redirect URI keeps the name; the browser resolves it on its own --- shared/src/main/kotlin/auth/OAuthFlow.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/src/main/kotlin/auth/OAuthFlow.kt b/shared/src/main/kotlin/auth/OAuthFlow.kt index 1417fec..6699a82 100644 --- a/shared/src/main/kotlin/auth/OAuthFlow.kt +++ b/shared/src/main/kotlin/auth/OAuthFlow.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.* import util.AppDirs import util.Http import util.Log +import java.net.InetAddress import java.net.InetSocketAddress import java.net.ServerSocket import java.security.MessageDigest @@ -111,7 +112,10 @@ suspend fun runGoogleLogin(creds: OAuthCredentials, openBrowser: (String) -> Uni ServerSocket().apply { reuseAddress = true soTimeout = CALLBACK_TIMEOUT_MS - bind(InetSocketAddress("localhost", 0)) + // The loopback address itself, not the name: resolving "localhost" goes through the + // system resolver, which is the one thing a sign-in should not depend on. The redirect + // URI keeps the name because the browser resolves it on its own. + bind(InetSocketAddress(InetAddress.getLoopbackAddress(), 0)) } } synchronized(pendingLock) { From da264b8d52a5b79a96179edd9477380d876db3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 15:59:55 +0100 Subject: [PATCH 2/5] feat(feed): open shelf track lists as a navigable list instead of playing - A shelf that carries tracks (weekly discovery, quick picks) renders as one card that opens the list; playback starts only from an explicit tap on a track or on play_all there. Tapping a card used to start the queue at once. - Cards can travel with their track list, so a shelf built from data already in hand does not round-trip through collectionTracks. --- .../main/kotlin/com/wren/app/ui/FeedScreen.kt | 140 ++++++++++++++---- desktop/src/main/kotlin/ui/FeedScreen.kt | 54 ++++++- shared/src/main/kotlin/models/Models.kt | 4 +- .../kotlin/provider/SoundCloudProvider.kt | 18 ++- .../main/kotlin/provider/YouTubeProvider.kt | 12 +- 5 files changed, 191 insertions(+), 37 deletions(-) diff --git a/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt b/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt index 3ade346..973df3a 100644 --- a/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt +++ b/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt @@ -1,11 +1,13 @@ package com.wren.app.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -15,12 +17,14 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import models.SearchResult import models.Shelf import models.ShelfCard import models.ShelfCardKind @@ -29,15 +33,19 @@ import player.PlayerEngine import provider.MusicProvider import util.runCatchingExceptCancellation -/** A card that opened another feed rather than a track list. `null` shelves means "still loading". */ -private data class OpenShelves(val card: ShelfCard, val shelves: List?) +/** A card's destination. `null` lists mean "still loading", never empty — mirroring the desktop screen. */ +private sealed interface Open { + val card: ShelfCard + data class Tracks(override val card: ShelfCard, val tracks: List?) : Open + data class Shelves(override val card: ShelfCard, val shelves: List?) : Open +} /** * A provider feed rendered as shelves. Home and Explore are the same screen with a different * fetch, so they share this one. * - * A track card goes straight into the queue; a mood or genre card opens another feed, so those - * are a stack and back leaves one level at a time. + * A card opens a navigable track list (playback is only ever started by an explicit tap there); + * a mood or genre card opens another feed, so those are a stack and back leaves one level at a time. * * A `null` shelf list means "not loaded yet", which is not the same as an empty one — the empty * state only appears once the fetch has actually come back with nothing. @@ -50,7 +58,7 @@ private fun FeedScreen( load: suspend () -> List, ) { var shelves by remember(provider) { mutableStateOf?>(null) } - val opened = remember(provider) { mutableStateListOf() } + val opened = remember(provider) { mutableStateListOf() } val scope = rememberCoroutineScope() LaunchedEffect(provider) { @@ -59,16 +67,24 @@ private fun FeedScreen( fun open(card: ShelfCard) { when (card.kind) { - ShelfCardKind.TRACKS -> scope.launch { - val tracks = runCatching { provider.collectionTracks(card.id) }.getOrDefault(emptyList()) - if (tracks.isNotEmpty()) engine.loadQueue(tracks.map { it.toQueueItem() }, 0, card.title) + ShelfCardKind.TRACKS -> { + // Opening a list must never start playback on its own: it lands on the track + // list, and only an explicit tap there (a track or play_all) starts playing. + val index = opened.size + opened.add(Open.Tracks(card, null)) + scope.launch { + val tracks = card.tracks ?: runCatchingExceptCancellation { + provider.collectionTracks(card.id) + }.getOrDefault(emptyList()) + opened[index] = Open.Tracks(card, tracks) + } } ShelfCardKind.SHELVES -> { val index = opened.size - opened.add(OpenShelves(card, null)) + opened.add(Open.Shelves(card, null)) scope.launch { - val nested = runCatching { provider.collectionShelves(card.id) }.getOrDefault(emptyList()) - opened[index] = OpenShelves(card, nested) + val nested = runCatchingExceptCancellation { provider.collectionShelves(card.id) }.getOrDefault(emptyList()) + opened[index] = Open.Shelves(card, nested) } } } @@ -96,15 +112,45 @@ private fun FeedScreen( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (top is Open.Tracks && top.tracks?.isNotEmpty() == true) { + Spacer(Modifier.weight(1f)) + Text( + "play_all", + color = TextPrimary, + fontFamily = FontMono, + fontSize = 12.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { engine.loadQueue(top.tracks.map { it.toQueueItem() }, 0, top.card.title) } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } } } - val nested = top?.shelves when { - top != null -> when { - nested == null -> Loading() - nested.isEmpty() -> Message("nothing_in_here") - else -> ShelfList(nested, engine, ::open) + top is Open.Tracks -> when (val tracks = top.tracks) { + null -> Loading() + else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 12.dp)) { + if (tracks.isEmpty()) { + item { Message("no_playable_tracks") } + } else { + itemsIndexed(tracks, key = { index, item -> "open:${top.card.id}:$index:${item.videoId}" }) { index, item -> + TrackRow( + trackKey = item.videoId, + title = item.title, + subtitle = item.subtitleText(), + artworkUrl = item.thumbnailUrl.ifBlank { null }, + onClick = { engine.loadQueue(tracks.map { it.toQueueItem() }, index, top.card.title) }, + ) + } + } + } + } + top is Open.Shelves -> when { + top.shelves == null -> Loading() + top.shelves.orEmpty().isEmpty() -> Message("nothing_in_here") + else -> ShelfList(top.shelves.orEmpty(), engine, ::open) } shelves == null -> Loading() shelves.orEmpty().isEmpty() -> Message(emptyHint) @@ -151,17 +197,29 @@ private fun ShelfList(shelves: List, engine: PlayerEngine, onOpen: (Shelf } } } - itemsIndexed( - shelf.tracks, - key = { index, item -> "track:$sIdx:$index:${item.videoId}" }, - ) { index, item -> - TrackRow( - trackKey = item.videoId, - title = item.title, - subtitle = item.subtitleText(), - artworkUrl = item.thumbnailUrl.ifBlank { null }, - onClick = { engine.loadQueue(shelf.tracks.map { it.toQueueItem() }, index, shelf.title) }, - ) + if (shelf.tracks.isNotEmpty()) { + // A shelf carrying a track list renders as one navigable entry — never inline + // play rows: playing starts only from an explicit tap inside the opened list. + item(key = "list:$sIdx") { + val tracks = shelf.tracks + ListCard( + title = shelf.title, + subtitle = shelf.caption ?: "${tracks.size} tracks", + count = tracks.size, + artworkUrl = tracks.firstOrNull { it.thumbnailUrl.isNotBlank() }?.thumbnailUrl, + modifier = Modifier.padding(horizontal = 16.dp), + onClick = { + onOpen( + ShelfCard( + id = "shelf-$sIdx", + title = shelf.title, + subtitle = shelf.caption, + tracks = tracks + ) + ) + } + ) + } } } } @@ -175,6 +233,34 @@ fun HomeScreen(provider: MusicProvider, engine: PlayerEngine) = fun ExploreScreen(provider: MusicProvider, engine: PlayerEngine) = FeedScreen(provider, engine, provider.exploreEmptyHint) { provider.explore() } +@Composable +private fun ListCard( + title: String, + subtitle: String, + count: Int, + artworkUrl: String?, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Row( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(PsSteel400.copy(alpha = 0.08f)) + .clickable(onClick = onClick) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Artwork(artworkUrl, Modifier.size(56.dp)) + Column { + Text(title, color = TextPrimary, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = TextSecondary, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("$count tracks", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp) + } + } +} + @Composable private fun SectionHeader(shelf: Shelf) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { diff --git a/desktop/src/main/kotlin/ui/FeedScreen.kt b/desktop/src/main/kotlin/ui/FeedScreen.kt index 9e12a50..dc3fab6 100644 --- a/desktop/src/main/kotlin/ui/FeedScreen.kt +++ b/desktop/src/main/kotlin/ui/FeedScreen.kt @@ -76,9 +76,13 @@ private fun FeedScreen( when (card.kind) { ShelfCardKind.TRACKS -> { opened.add(Open.Tracks(card, null)) - val tracks = runCatching { provider.collectionTracks(card.id) }.getOrDefault(emptyList()) - opened[index] = Open.Tracks(card, tracks) - tracks.take(6).forEach { launch { resolveStreamUrl(it.videoId) } } + if (card.tracks != null) { + opened[index] = Open.Tracks(card, card.tracks) + } else { + val tracks = runCatching { provider.collectionTracks(card.id) }.getOrDefault(emptyList()) + opened[index] = Open.Tracks(card, tracks) + tracks.take(6).forEach { launch { resolveStreamUrl(it.videoId) } } + } } ShelfCardKind.SHELVES -> { opened.add(Open.Shelves(card, null)) @@ -186,8 +190,26 @@ private fun ShelfList(shelves: List, player: FFmpegPlayer, onOpen: (Shelf } } if (shelf.tracks.isNotEmpty()) { - items(shelf.tracks.size, key = { "track:$sIdx:$it:${shelf.tracks[it].videoId}" }) { index -> - TrackRow(shelf.tracks[index], index, shelf.tracks, player, onArtistClick = null) + // A shelf that carries a track list opens as a navigable list — never a row of + // instant-play rows. Playing starts only from an explicit action inside the list. + item(key = "list-$sIdx") { + val tracks = shelf.tracks + ListCard( + title = shelf.title, + subtitle = shelf.caption ?: "${tracks.size} tracks", + count = tracks.size, + artworkUrl = tracks.firstNotNullOfOrNull { it.thumbnailUrl.ifBlank { null } }, + onClick = { + onOpen( + ShelfCard( + id = "shelf-$sIdx", + title = shelf.title, + subtitle = shelf.caption, + tracks = tracks + ) + ) + } + ) } } } @@ -202,6 +224,28 @@ fun HomeScreen(provider: MusicProvider, player: FFmpegPlayer) = fun ExploreScreen(provider: MusicProvider, player: FFmpegPlayer) = FeedScreen(provider, player, "explore", provider.exploreEmptyHint) { provider.explore() } +@Composable +private fun ListCard( + title: String, + subtitle: String, + count: Int, + artworkUrl: String?, + onClick: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Thumbnail(artworkUrl ?: "", Modifier.size(64.dp)) + Column { + Text(title, color = TextPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = TextSecondary, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("$count tracks — open_the_list", color = PsSteel400, fontFamily = FontMono, fontSize = 10.sp) + } + } +} + @Composable private fun CollectionCard(card: ShelfCard, onClick: () -> Unit) { Column( diff --git a/shared/src/main/kotlin/models/Models.kt b/shared/src/main/kotlin/models/Models.kt index 6b504b0..69905ca 100644 --- a/shared/src/main/kotlin/models/Models.kt +++ b/shared/src/main/kotlin/models/Models.kt @@ -122,7 +122,9 @@ data class ShelfCard( val title: String, val subtitle: String? = null, val artworkUrl: String? = null, - val kind: ShelfCardKind = ShelfCardKind.TRACKS + val kind: ShelfCardKind = ShelfCardKind.TRACKS, + /** Set when the list travels with the shelf and no `collectionTracks` roundtrip is needed. */ + val tracks: List? = null ) /** One feed shelf. A flat track list, a row of cards, or both — a screen renders what is non-empty. */ diff --git a/shared/src/main/kotlin/provider/SoundCloudProvider.kt b/shared/src/main/kotlin/provider/SoundCloudProvider.kt index 3dca9f4..545b76b 100644 --- a/shared/src/main/kotlin/provider/SoundCloudProvider.kt +++ b/shared/src/main/kotlin/provider/SoundCloudProvider.kt @@ -69,8 +69,22 @@ private const val TAG = "SoundCloud" val basis = it.basisGenres.joinToString(", ") Shelf( title = "weekly discovery", - caption = buildString { append("generated $generated"); if (basis.isNotEmpty()) append(" · based_on: $basis") }, - tracks = it.tracks + caption = buildString { + append("generated $generated") + if (basis.isNotEmpty()) append(" · based_on: $basis") + append(" · "); append(it.tracks.size); append(" tracks — open_the_list") + }, + cards = listOfNotNull( + it.tracks.takeIf(List::isNotEmpty)?.let { tracks -> + ShelfCard( + id = "weekly-discovery", + title = "weekly discovery", + subtitle = "generated $generated", + artworkUrl = tracks.firstNotNullOfOrNull { t -> t.thumbnailUrl.ifBlank { null } }, + tracks = tracks + ) + } + ) ) } return listOfNotNull(weeklySection) + selections { it.isPersonal() } diff --git a/shared/src/main/kotlin/provider/YouTubeProvider.kt b/shared/src/main/kotlin/provider/YouTubeProvider.kt index 6e7ee80..020da01 100644 --- a/shared/src/main/kotlin/provider/YouTubeProvider.kt +++ b/shared/src/main/kotlin/provider/YouTubeProvider.kt @@ -128,8 +128,16 @@ object YouTubeProvider : MusicProvider { picks.takeIf { it.isNotEmpty() }?.let { Shelf( title = "quick picks", - caption = "radios wren built from what you play here", - tracks = it, + caption = "radios wren built from what you play here — ${it.size} tracks — open_the_list", + cards = listOf( + ShelfCard( + id = "quick-picks", + title = "quick picks", + subtitle = "${it.size} tracks", + artworkUrl = it.firstNotNullOfOrNull { t -> t.thumbnailUrl.ifBlank { null } }, + tracks = it + ) + ) ) }, releases.takeIf { it.isNotEmpty() }?.let { From a35c9ff379aeabc9907bad52958905c513dbc0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 15:59:55 +0100 Subject: [PATCH 3/5] fix(ui): keep the feed header one row tall with a long card title - The title had no weight, so a long one took the whole row and left play_all a sliver in which it wrapped one letter per line, stretching the header to a fifth of the screen. The title now flexes and ellipsizes and the action never wraps. --- android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt b/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt index 973df3a..6fc1197 100644 --- a/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt +++ b/android/app/src/main/kotlin/com/wren/app/ui/FeedScreen.kt @@ -104,6 +104,8 @@ private fun FeedScreen( IconButton(onClick = { opened.removeAt(opened.lastIndex) }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = TextPrimary) } + // The title flexes and the action keeps its width: a long title otherwise takes + // the whole row, leaving `play_all` a sliver in which it wraps one letter per line. Text( top.card.title, color = TextPrimary, @@ -111,14 +113,16 @@ private fun FeedScreen( fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) if (top is Open.Tracks && top.tracks?.isNotEmpty() == true) { - Spacer(Modifier.weight(1f)) Text( "play_all", color = TextPrimary, fontFamily = FontMono, fontSize = 12.sp, + maxLines = 1, + softWrap = false, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .clickable { engine.loadQueue(top.tracks.map { it.toQueueItem() }, 0, top.card.title) } From 465464bfdf7cb8ea2fbe991ab64d9168ffe5fec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 15:59:55 +0100 Subject: [PATCH 4/5] feat(library): refresh on entry and on pull, hide the SoundCloud like toggle - A library list older than a minute is read again when the screen is entered, and pulling down forgets every cached list and re-reads the one on screen, so a like or playlist edit made on the platform itself shows up without waiting out the ten-minute cache. - Hearts are no longer offered as a control for SoundCloud: its bot protection refuses writes from anything but its own player, and refused writes carrying a real token are a signal against the account. Likes are still read. The write path lives on feat/soundcloud-like-writes. --- .../kotlin/com/wren/app/ui/LibraryScreen.kt | 92 ++++++++++++++++--- .../com/wren/app/ui/NowPlayingScreen.kt | 2 +- .../kotlin/com/wren/app/ui/SearchScreen.kt | 2 +- .../kotlin/com/wren/app/widget/WrenWidget.kt | 3 +- shared/src/main/kotlin/api/SoundCloudLikes.kt | 12 ++- .../src/main/kotlin/provider/MusicProvider.kt | 7 ++ .../kotlin/provider/SoundCloudProvider.kt | 6 ++ .../main/kotlin/provider/YouTubeProvider.kt | 9 ++ shared/src/main/kotlin/util/TtlCache.kt | 12 +++ shared/src/test/kotlin/util/TtlCacheTest.kt | 14 +++ 10 files changed, 142 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/com/wren/app/ui/LibraryScreen.kt b/android/app/src/main/kotlin/com/wren/app/ui/LibraryScreen.kt index 87e20d0..0db2e60 100644 --- a/android/app/src/main/kotlin/com/wren/app/ui/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/wren/app/ui/LibraryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text @@ -12,6 +13,10 @@ import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.PullRefreshState +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +41,13 @@ import provider.MusicProvider import util.connectMessage import util.runCatchingExceptCancellation +/** + * How old a cached library list may be when the screen is entered before it is read again. The + * cache itself lives for minutes so that hopping between tabs costs nothing; this is what keeps a + * like made elsewhere from hiding behind it for that long. + */ +private const val LIBRARY_REVALIDATE_MS = 60_000L + private enum class LibraryTab(val label: String) { SONGS("songs"), PLAYLISTS("playlists"), @@ -47,6 +59,7 @@ private enum class LibraryTab(val label: String) { * artists behind them. SoundCloud has no artist browsing, so that tab only shows up * for YouTube ([MusicProvider.supportsArtists]). */ +@OptIn(ExperimentalMaterialApi::class) @Composable fun LibraryScreen( provider: MusicProvider, @@ -70,13 +83,15 @@ fun LibraryScreen( var artistsError by remember(provider) { mutableStateOf(null) } var tracksError by remember(provider) { mutableStateOf(null) } var retry by remember(provider) { mutableStateOf(0) } + var revalidated by remember(provider) { mutableStateOf(false) } + var refreshing by remember(provider) { mutableStateOf(false) } var tracksJob by remember(provider) { mutableStateOf(null) } val scope = rememberCoroutineScope() val queue by engine.queue.collectAsState() val queueIndex by engine.queueIndex.collectAsState() val liked by SoundCloudLikes.liked.collectAsState() - val canLike = provider.platform == Platform.SOUNDCLOUD && provider.isAuthenticated + val canLike = SoundCloudLikes.CAN_TOGGLE && provider.platform == Platform.SOUNDCLOUD && provider.isAuthenticated val onLike: ((PlaylistTrack) -> Unit)? = if (canLike) { { track -> scope.launch { SoundCloudLikes.toggle(track.url) } } } else null @@ -92,6 +107,12 @@ fun LibraryScreen( } LaunchedEffect(provider, tab, retry) { + // Once per visit, before the first read: a list older than a minute is fetched again + // rather than served, so a change made on the platform itself shows up on entry. + if (!revalidated) { + revalidated = true + provider.invalidateLibrary(olderThanMs = LIBRARY_REVALIDATE_MS) + } if (tab == LibraryTab.SONGS && songs == null) { loading = true var prefetched = false @@ -123,6 +144,7 @@ fun LibraryScreen( .collect { page -> artists = page; if (page.isNotEmpty()) loading = false } loading = false } + refreshing = false } fun loadPlaylistTracks(playlist: Playlist) { @@ -133,12 +155,32 @@ fun LibraryScreen( .catch { failure -> tracksError = failure.connectMessage(); loading = false } .collect { page -> tracks = page; if (page.isNotEmpty()) loading = false } loading = false + refreshing = false } } // Back leaves an open playlist before it leaves the tab. BackHandler(enabled = selectedPlaylist != null) { selectedPlaylist = null; tracks = null } + /** Pull-to-refresh: forget every cached list and read the one on screen again. */ + fun refresh() { + if (refreshing) return + refreshing = true + scope.launch { + provider.invalidateLibrary() + val open = selectedPlaylist + if (open != null) { + tracks = null; tracksError = null + loadPlaylistTracks(open) + } else { + songs = null; playlists = null; artists = null + songsError = null; playlistsError = null; artistsError = null + retry++ + } + } + } + val pullState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh) + Column(Modifier.fillMaxSize()) { val open = selectedPlaylist if (open != null) { @@ -158,10 +200,12 @@ fun LibraryScreen( overflow = TextOverflow.Ellipsis, ) } - if (tracksError != null) { - LibraryError(tracksError!!) { loadPlaylistTracks(open) } - } else { - PlaylistTrackList(tracks, loading, engine, currentId, "this playlist is empty", queueTitle = open.title, liked = liked, onLike = onLike) + Refreshable(pullState, refreshing) { + if (tracksError != null) { + LibraryError(tracksError!!) { loadPlaylistTracks(open) } + } else { + PlaylistTrackList(tracks, loading, engine, currentId, "this playlist is empty", queueTitle = open.title, liked = liked, onLike = onLike) + } } } else { LibraryTabSelector(tabs, tab) { tab = it } @@ -170,20 +214,42 @@ fun LibraryScreen( LibraryTab.PLAYLISTS -> playlistsError LibraryTab.ARTISTS -> artistsError } - if (error != null) { - LibraryError(error) { retry++ } - } else when (tab) { - LibraryTab.SONGS -> PlaylistTrackList(songs, loading, engine, currentId, "no liked songs yet", queueTitle = "liked songs", liked = liked, onLike = onLike) - LibraryTab.PLAYLISTS -> PlaylistList(playlists, loading) { playlist -> - selectedPlaylist = playlist - loadPlaylistTracks(playlist) + Refreshable(pullState, refreshing) { + if (error != null) { + LibraryError(error) { retry++ } + } else when (tab) { + LibraryTab.SONGS -> PlaylistTrackList(songs, loading, engine, currentId, "no liked songs yet", queueTitle = "liked songs", liked = liked, onLike = onLike) + LibraryTab.PLAYLISTS -> PlaylistList(playlists, loading) { playlist -> + selectedPlaylist = playlist + loadPlaylistTracks(playlist) + } + LibraryTab.ARTISTS -> ArtistList(artists, loading, onArtistSearch) } - LibraryTab.ARTISTS -> ArtistList(artists, loading, onArtistSearch) } } } } +/** The list area with the pull gesture attached and its indicator drawn over the top edge. */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun Refreshable( + state: PullRefreshState, + refreshing: Boolean, + content: @Composable () -> Unit, +) { + Box(Modifier.fillMaxSize().pullRefresh(state)) { + content() + PullRefreshIndicator( + refreshing = refreshing, + state = state, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = Chrome, + contentColor = PsIrisCyan, + ) + } +} + /** A failed fetch, with the way out: the list stays null, so retrying is the only exit. */ @Composable private fun LibraryError(message: String, onRetry: () -> Unit) { diff --git a/android/app/src/main/kotlin/com/wren/app/ui/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/wren/app/ui/NowPlayingScreen.kt index cddee27..9c3142f 100644 --- a/android/app/src/main/kotlin/com/wren/app/ui/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/wren/app/ui/NowPlayingScreen.kt @@ -132,7 +132,7 @@ fun NowPlayingScreen(engine: PlayerEngine) { val download: (() -> Unit)? = item ?.takeIf { it.source == Source.SOUNDCLOUD && allowSoundCloudDownloads } ?.let { track -> { DownloadManager.enqueue(track, downloadsDestination(context)) } } - val like: (() -> Unit)? = item?.takeIf { it.source == Source.SOUNDCLOUD && SoundCloudAuth.isAuthenticated }?.let { track -> + val like: (() -> Unit)? = item?.takeIf { it.source == Source.SOUNDCLOUD && SoundCloudLikes.CAN_TOGGLE && SoundCloudAuth.isAuthenticated }?.let { track -> { scope.launch { SoundCloudLikes.toggle(track.url) } } } diff --git a/android/app/src/main/kotlin/com/wren/app/ui/SearchScreen.kt b/android/app/src/main/kotlin/com/wren/app/ui/SearchScreen.kt index 75b0cfe..519523b 100644 --- a/android/app/src/main/kotlin/com/wren/app/ui/SearchScreen.kt +++ b/android/app/src/main/kotlin/com/wren/app/ui/SearchScreen.kt @@ -44,7 +44,7 @@ fun SearchScreen( val context = LocalContext.current val downloads by DownloadManager.states.collectAsState() val liked by SoundCloudLikes.liked.collectAsState() - val canLike = provider.platform == Platform.SOUNDCLOUD && provider.isAuthenticated + val canLike = SoundCloudLikes.CAN_TOGGLE && provider.platform == Platform.SOUNDCLOUD && provider.isAuthenticated fun doSearch() { if (query.isBlank()) return diff --git a/android/app/src/main/kotlin/com/wren/app/widget/WrenWidget.kt b/android/app/src/main/kotlin/com/wren/app/widget/WrenWidget.kt index a323c8f..2a50af8 100644 --- a/android/app/src/main/kotlin/com/wren/app/widget/WrenWidget.kt +++ b/android/app/src/main/kotlin/com/wren/app/widget/WrenWidget.kt @@ -119,7 +119,8 @@ private data class WrenWidgetState( /** The platform's own "kept" state: a SoundCloud like, or a YouTube like (the collection). */ private fun likedOf(item: QueueItem): Boolean? = when (item.source) { - Source.SOUNDCLOUD -> if (SoundCloudAuth.isAuthenticated) SoundCloudLikes.isLiked(item.url) else null + // On the widget the heart is a button, so it is not shown while likes cannot be written. + Source.SOUNDCLOUD -> if (SoundCloudLikes.CAN_TOGGLE && SoundCloudAuth.isAuthenticated) SoundCloudLikes.isLiked(item.url) else null else -> if (GoogleAuth.isAuthenticated) YouTubeLikes.isLiked(item.videoId) else null } diff --git a/shared/src/main/kotlin/api/SoundCloudLikes.kt b/shared/src/main/kotlin/api/SoundCloudLikes.kt index 5c8f326..32b28cc 100644 --- a/shared/src/main/kotlin/api/SoundCloudLikes.kt +++ b/shared/src/main/kotlin/api/SoundCloudLikes.kt @@ -15,6 +15,15 @@ import util.Log * Toggling is optimistic: the heart flips at once and rolls back if the API says no. */ object SoundCloudLikes { + /** + * Whether the heart is offered as a control at all. SoundCloud's bot protection refuses + * writes from anything but its own player — every attempt from a phone ends in a DataDome + * block (the full retry-through-a-browser-and-captcha path lives on the + * `feat/soundcloud-like-writes` branch) — and a stream of refused writes carrying a real + * token is a signal against the account. Until that changes, likes are read, never written. + */ + const val CAN_TOGGLE: Boolean = false + private val _liked = MutableStateFlow>(emptySet()) /** Permalinks of every liked track; empty when signed out. */ val liked: StateFlow> = _liked.asStateFlow() @@ -47,7 +56,8 @@ object SoundCloudLikes { * already has the numeric id (search results do; queue items do not). */ suspend fun toggle(permalink: String, knownId: Long? = null): Boolean { - if (!SoundCloudAuth.isAuthenticated) return false + // The screens hide the control; this is the backstop for any path that still reaches it. + if (!CAN_TOGGLE || !SoundCloudAuth.isAuthenticated) return false val id = knownId ?: lock.withLock { ids[permalink] } ?: SoundCloud.resolveTrackId(permalink) diff --git a/shared/src/main/kotlin/provider/MusicProvider.kt b/shared/src/main/kotlin/provider/MusicProvider.kt index 966f98a..ceec2d8 100644 --- a/shared/src/main/kotlin/provider/MusicProvider.kt +++ b/shared/src/main/kotlin/provider/MusicProvider.kt @@ -65,6 +65,13 @@ interface MusicProvider { /** The user's liked tracks. Empty when unsupported or unauthenticated. */ suspend fun librarySongs(): List = emptyList() + + /** + * Forgets the cached library lists — all of them, or only those loaded more than + * [olderThanMs] ago — so the next read walks the platform again. The Library calls it on + * entry with a short age and on pull-to-refresh with none. + */ + suspend fun invalidateLibrary(olderThanMs: Long = 0) {} /** Artists behind the user's library (follows/subscriptions). Empty when unsupported. */ suspend fun libraryArtists(): List = emptyList() diff --git a/shared/src/main/kotlin/provider/SoundCloudProvider.kt b/shared/src/main/kotlin/provider/SoundCloudProvider.kt index 545b76b..551eaaf 100644 --- a/shared/src/main/kotlin/provider/SoundCloudProvider.kt +++ b/shared/src/main/kotlin/provider/SoundCloudProvider.kt @@ -166,6 +166,12 @@ private const val TAG = "SoundCloud" private fun playlistKey(playlistId: String): String = "${account()}|$playlistId" + override suspend fun invalidateLibrary(olderThanMs: Long) { + songs.evictOlderThan(olderThanMs) + playlists.evictOlderThan(olderThanMs) + playlistTracks.evictOlderThan(olderThanMs) + } + override suspend fun librarySongs(): List = songs.getOrLoad(account()) { SoundCloud.userLikes(SoundCloudAuth.userId ?: return@getOrLoad emptyList()).map { it.toPlaylistTrack() } } } diff --git a/shared/src/main/kotlin/provider/YouTubeProvider.kt b/shared/src/main/kotlin/provider/YouTubeProvider.kt index 020da01..9539ecb 100644 --- a/shared/src/main/kotlin/provider/YouTubeProvider.kt +++ b/shared/src/main/kotlin/provider/YouTubeProvider.kt @@ -219,6 +219,15 @@ object YouTubeProvider : MusicProvider { * an empty list rather than a fetch, so a provider that does not implement them serves an * empty library and logs nothing while doing it. */ + override suspend fun invalidateLibrary(olderThanMs: Long) { + songs.evictOlderThan(olderThanMs) + playlists.evictOlderThan(olderThanMs) + artists.evictOlderThan(olderThanMs) + playlistTracks.evictOlderThan(olderThanMs) + // Followed artists feed the library's artist tab, so a refresh there re-reads them too. + subscriptions.evictOlderThan(olderThanMs) + } + override fun librarySongsFlow(): Flow> = pagedFlow(TAG, songs, account()) { cursor -> likedSongsPage(cursor) } diff --git a/shared/src/main/kotlin/util/TtlCache.kt b/shared/src/main/kotlin/util/TtlCache.kt index 93df07a..8a819c0 100644 --- a/shared/src/main/kotlin/util/TtlCache.kt +++ b/shared/src/main/kotlin/util/TtlCache.kt @@ -53,4 +53,16 @@ class TtlCache(private val ttlMs: Long, private val maxEntries suspend fun clear() { lock.withLock { entries.clear() } } + + /** + * Drops what was loaded more than [ageMs] ago, still-fresh or not. For a screen that wants a + * list re-read on entry once it is a minute old, without giving up the cache for the tab + * hopping in between. + */ + suspend fun evictOlderThan(ageMs: Long) { + lock.withLock { + val cutoff = System.currentTimeMillis() - ageMs + entries.entries.removeAll { it.value.loadedAt < cutoff } + } + } } diff --git a/shared/src/test/kotlin/util/TtlCacheTest.kt b/shared/src/test/kotlin/util/TtlCacheTest.kt index ed105aa..c0c325c 100644 --- a/shared/src/test/kotlin/util/TtlCacheTest.kt +++ b/shared/src/test/kotlin/util/TtlCacheTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -105,4 +106,17 @@ class TtlCacheTest { assertEquals(3, afterB) assertEquals(4, loads.get()) } + + @Test + fun `evicts by age, keeping what is younger`() = runBlocking { + val cache = TtlCache(ttlMs = 60_000) + cache.put("old", "a") + Thread.sleep(30) + cache.put("young", "b") + + cache.evictOlderThan(15) + + assertNull(cache.peek("old")) + assertEquals("b", cache.peek("young")) + } } From 20c1ef6e59a9d5b5f0b03944f74afca94e0c2940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20S=C3=A1nchez?= Date: Mon, 14 Sep 2026 16:07:40 +0100 Subject: [PATCH 5/5] build(desktop): bump the release version to 0.6.1 --- desktop/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 09e08a0..7b585ad 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -89,7 +89,7 @@ kotlin { // The real release version. Compose Desktop's Dmg validation requires MAJOR > 0, so macOS // gets its own jpackage-internal version below; the public artifact still gets renamed to // this version in CI. Deb/Msi have no such restriction and use it directly. -val appVersion = "0.6.0" +val appVersion = "0.6.1" compose.desktop { application {