M1/#292: core library schema + sqlc + admin bootstrap #5
@@ -38,6 +38,16 @@ interface PlaylistsApi {
|
|||||||
@Path("id") playlistId: String,
|
@Path("id") playlistId: String,
|
||||||
@Body body: AppendTracksRequest,
|
@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(
|
data class AppendTracksRequest(
|
||||||
@SerialName("track_ids") val trackIds: List<String>,
|
@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 ownerUsername: String = "",
|
||||||
) {
|
) {
|
||||||
val isSystem: Boolean get() = systemVariant != null
|
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
|
* is already in the playlist, the IGNORE on conflict leaves the
|
||||||
* existing position unchanged.
|
* 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 {
|
suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome {
|
||||||
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
|
val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
|
||||||
playlistTrackDao.insertOrIgnore(
|
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.ListMusic
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Play
|
import com.composables.icons.lucide.Play
|
||||||
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
@@ -125,6 +126,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val deletedChannel = Channel<Unit>(Channel.BUFFERED)
|
private val deletedChannel = Channel<Unit>(Channel.BUFFERED)
|
||||||
val deleted: Flow<Unit> = deletedChannel.receiveAsFlow()
|
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>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTracks()
|
likes.observeLikedTracks()
|
||||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
.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). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
|
val playable = tracks.filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
|
||||||
@@ -209,6 +239,16 @@ fun PlaylistDetailScreen(
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.deleted.collect { navController.popBackStack() }
|
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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -241,6 +281,7 @@ fun PlaylistDetailScreen(
|
|||||||
onShuffleAll = {
|
onShuffleAll = {
|
||||||
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
|
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
|
||||||
},
|
},
|
||||||
|
onRegenerate = viewModel::regenerate,
|
||||||
onTrackClick = { id ->
|
onTrackClick = { id ->
|
||||||
viewModel.play(s.detail.tracks, startTrackId = id)
|
viewModel.play(s.detail.tracks, startTrackId = id)
|
||||||
},
|
},
|
||||||
@@ -268,6 +309,7 @@ private fun PlaylistDetailBody(
|
|||||||
playingTrackId: String?,
|
playingTrackId: String?,
|
||||||
onPlayAll: () -> Unit,
|
onPlayAll: () -> Unit,
|
||||||
onShuffleAll: () -> Unit,
|
onShuffleAll: () -> Unit,
|
||||||
|
onRegenerate: () -> Unit,
|
||||||
onTrackClick: (String) -> Unit,
|
onTrackClick: (String) -> Unit,
|
||||||
onToggleTrackLike: (String) -> Unit,
|
onToggleTrackLike: (String) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
@@ -277,7 +319,7 @@ private fun PlaylistDetailBody(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 140.dp),
|
contentPadding = PaddingValues(bottom = 140.dp),
|
||||||
) {
|
) {
|
||||||
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll) }
|
item { PlaylistHeader(detail.playlist, onPlayAll, onShuffleAll, onRegenerate) }
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
items(items = detail.tracks, key = { it.position }) { row ->
|
items(items = detail.tracks, key = { it.position }) { row ->
|
||||||
TrackRow(
|
TrackRow(
|
||||||
@@ -309,6 +351,7 @@ private fun PlaylistHeader(
|
|||||||
playlist: PlaylistRef,
|
playlist: PlaylistRef,
|
||||||
onPlayAll: () -> Unit,
|
onPlayAll: () -> Unit,
|
||||||
onShuffleAll: () -> Unit,
|
onShuffleAll: () -> Unit,
|
||||||
|
onRegenerate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -360,6 +403,17 @@ private fun PlaylistHeader(
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Shuffle")
|
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