feat(android): PlaylistDetail Regenerate button + refreshSystem endpoint

Audit v2 #15. System-playlist detail screens now expose a
"Regenerate" OutlinedButton next to Play + Shuffle when the playlist
is refreshable (every system variant except songs_like_artist —
mirrors Flutter's PlaylistRef.refreshable).

Plumbing:
* PlaylistsApi.refreshSystem(variant) → RefreshSystemResponse with
  optional playlist_id (server rotates the uuid; null when library
  can't seed a build).
* PlaylistsRepository.refreshSystemPlaylist returns the new id and
  invalidates the list cache so home-row tiles point at the new uuid.
* PlaylistDetailViewModel.regenerate calls into the repo and emits
  the new id on a Channel.
* Screen collects the channel and navController.navigate-with-popUpTo
  replaces the current detail route, so the back stack doesn't hold
  the now-404'd old uuid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:54:47 -04:00
parent 49a5324be8
commit 7500d283a8
4 changed files with 96 additions and 1 deletions
@@ -38,6 +38,16 @@ interface PlaylistsApi {
@Path("id") playlistId: String,
@Body body: AppendTracksRequest,
)
/**
* POST /api/playlists/system/{kind}/refresh — rebuild the caller's
* system playlist of the given variant ("for_you" / "discover" /
* etc.). Returns the new playlist UUID (the rebuild rotates the
* id); playlistId is null when the library is empty / build can't
* pick seed tracks. The kind is the raw system_variant.
*/
@POST("api/playlists/system/{kind}/refresh")
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
}
/**
@@ -48,3 +58,13 @@ interface PlaylistsApi {
data class AppendTracksRequest(
@SerialName("track_ids") val trackIds: List<String>,
)
/**
* Response body for the system-refresh endpoint. `playlistId` is the
* uuid of the rebuilt playlist, or null when the build had nothing
* to seed from.
*/
@Serializable
data class RefreshSystemResponse(
@SerialName("playlist_id") val playlistId: String? = null,
)
@@ -22,6 +22,14 @@ data class PlaylistRef(
val ownerUsername: String = "",
) {
val isSystem: Boolean get() = systemVariant != null
/**
* Whether the "Regenerate" affordance applies. Mirrors Flutter's
* `Playlist.refreshable`: every system playlist EXCEPT
* songs_like_artist (server's *songs-like-X* family doesn't
* expose a refresh trigger; rebuilds are scheduler-driven).
*/
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
}
/**
@@ -112,6 +112,19 @@ class PlaylistsRepository @Inject constructor(
* is already in the playlist, the IGNORE on conflict leaves the
* existing position unchanged.
*/
/**
* Rebuilds the caller's system playlist of [variant]. The server
* rotates the playlist's UUID on rebuild — the returned id (null
* when the library can't seed a build) is what callers should
* navigate to. List cache is refreshed so home-row tiles point at
* the fresh uuid.
*/
suspend fun refreshSystemPlaylist(variant: String): String? {
val response = api.refreshSystem(variant)
runCatching { refreshList() }
return response.playlistId
}
suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome {
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
playlistTrackDao.insertOrIgnore(
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.RefreshCw
import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEvent
@@ -125,6 +126,14 @@ class PlaylistDetailViewModel @Inject constructor(
private val deletedChannel = Channel<Unit>(Channel.BUFFERED)
val deleted: Flow<Unit> = deletedChannel.receiveAsFlow()
/**
* One-shot signal: a system playlist regenerate landed and the
* server rotated the uuid. Screen replaces the current detail
* route with the new id so the user sees the fresh list.
*/
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
@@ -179,6 +188,27 @@ class PlaylistDetailViewModel @Inject constructor(
}
}
/**
* Server-side rebuild of this system playlist. No-op when the
* playlist isn't a refreshable system variant. On success the
* server rotates the playlist uuid; the new id arrives on
* [regenerated] for the screen to navigate-replace to.
*/
fun regenerate() {
val playlist = (internal.value as? PlaylistDetailUiState.Success)
?.detail?.playlist ?: return
val variant = playlist.systemVariant ?: return
if (!playlist.refreshable) return
viewModelScope.launch {
runCatching { repository.refreshSystemPlaylist(variant) }
.onSuccess { newId ->
if (!newId.isNullOrEmpty()) {
regeneratedChannel.trySend(newId)
}
}
}
}
/** Play the available tracks starting at [startTrackId] (or first available). */
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
@@ -209,6 +239,16 @@ fun PlaylistDetailScreen(
LaunchedEffect(Unit) {
viewModel.deleted.collect { navController.popBackStack() }
}
LaunchedEffect(Unit) {
viewModel.regenerated.collect { newId ->
// Replace the current detail route with the regenerated
// playlist's new uuid; pop the old entry so back doesn't
// land on a 404'd page.
navController.navigate(PlaylistDetail(newId)) {
popUpTo(PlaylistDetail(viewModel.playlistId)) { inclusive = true }
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -241,6 +281,7 @@ fun PlaylistDetailScreen(
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onRegenerate = viewModel::regenerate,
onTrackClick = { id ->
viewModel.play(s.detail.tracks, startTrackId = id)
},
@@ -268,6 +309,7 @@ private fun PlaylistDetailBody(
playingTrackId: String?,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onRegenerate: () -> Unit,
onTrackClick: (String) -> Unit,
onToggleTrackLike: (String) -> Unit,
onNavigateToAlbum: (String) -> Unit,
@@ -277,7 +319,7 @@ private fun PlaylistDetailBody(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp),
) {
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) }
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
item { HorizontalDivider() }
items(items = detail.tracks, key = { it.position }) { row ->
TrackRow(
@@ -309,6 +351,7 @@ private fun PlaylistHeader(
playlist: PlaylistRef,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onRegenerate: () -> Unit,
) {
Column(
modifier = Modifier
@@ -360,6 +403,17 @@ private fun PlaylistHeader(
Spacer(Modifier.width(8.dp))
Text("Shuffle")
}
if (playlist.refreshable) {
OutlinedButton(onClick = onRegenerate) {
Icon(
Lucide.RefreshCw,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Regenerate")
}
}
}
}
}