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:
@@ -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"
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+55
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user