diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt index 64b1e4fd..76aa94b0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -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, ) + +/** + * 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt index f1ab75ba..17e7bb4c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Playlist.kt @@ -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" } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index 0507a531..dba1000c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 9129d521..838ca200 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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(Channel.BUFFERED) val deleted: Flow = 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(Channel.BUFFERED) + val regenerated: Flow = regeneratedChannel.receiveAsFlow() + val likedTrackIds: StateFlow> = 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, 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") + } + } } } }