Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints #49

Merged
bvandeusen merged 262 commits from dev into main 2026-05-16 00:32:26 -04:00
4 changed files with 368 additions and 0 deletions
Showing only changes of commit d0ff607994 - Show all commits
@@ -27,10 +27,14 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -48,8 +52,12 @@ import com.composables.icons.lucide.Shuffle
import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.track_actions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -57,8 +65,15 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
fun AlbumDetailScreen( fun AlbumDetailScreen(
navController: NavHostController, navController: NavHostController,
viewModel: AlbumDetailViewModel = hiltViewModel(), viewModel: AlbumDetailViewModel = hiltViewModel(),
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
trackActionsViewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
@@ -71,6 +86,7 @@ fun AlbumDetailScreen(
}, },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) { Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) { when (val s = state) {
@@ -91,6 +107,8 @@ fun AlbumDetailScreen(
onTrackClick = { id -> viewModel.play(startTrackId = id) }, onTrackClick = { id -> viewModel.play(startTrackId = id) },
onToggleAlbumLike = viewModel::toggleLikeAlbum, onToggleAlbumLike = viewModel::toggleLikeAlbum,
onToggleTrackLike = viewModel::toggleLikeTrack, onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
) )
} }
} }
@@ -113,6 +131,8 @@ private fun AlbumBody(
onTrackClick: (String) -> Unit, onTrackClick: (String) -> Unit,
onToggleAlbumLike: () -> Unit, onToggleAlbumLike: () -> Unit,
onToggleTrackLike: (String) -> Unit, onToggleTrackLike: (String) -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -134,6 +154,8 @@ private fun AlbumBody(
liked = track.id in likedTrackIds, liked = track.id in likedTrackIds,
onClick = { onTrackClick(track.id) }, onClick = { onTrackClick(track.id) },
onToggleLike = { onToggleTrackLike(track.id) }, onToggleLike = { onToggleTrackLike(track.id) },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
) )
HorizontalDivider() HorizontalDivider()
} }
@@ -250,6 +272,8 @@ private fun TrackRow(
liked: Boolean, liked: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onToggleLike: () -> Unit, onToggleLike: () -> Unit,
onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -289,6 +313,11 @@ private fun TrackRow(
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
) )
LikeButton(liked = liked, onToggle = onToggleLike) LikeButton(liked = liked, onToggle = onToggleLike)
TrackActionsButton(
track = track,
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
} }
} }
@@ -0,0 +1,49 @@
package com.fabledsword.minstrel.shared.widgets.track_actions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.composables.icons.lucide.EllipsisVertical
import com.composables.icons.lucide.Lucide
import com.fabledsword.minstrel.models.TrackRef
/**
* Small kebab IconButton that opens [TrackActionsSheet] for [track].
* Drop into any track row alongside (or in place of) a LikeButton.
*
* [hideQueueActions] suppresses Play next + Add to queue — set true
* on the NowPlaying caller where the menu's track IS the playing one.
* [onNavigateToAlbum] / [onNavigateToArtist] are invoked AFTER the
* sheet has dismissed, so callers that live on a full-screen overlay
* (e.g. NowPlaying) can pop themselves first inside the callback.
*/
@Composable
fun TrackActionsButton(
track: TrackRef,
hideQueueActions: Boolean = false,
onNavigateToAlbum: (albumId: String) -> Unit,
onNavigateToArtist: (artistId: String) -> Unit,
) {
var show by remember { mutableStateOf(false) }
IconButton(onClick = { show = true }) {
Icon(
imageVector = Lucide.EllipsisVertical,
contentDescription = "Track actions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (show) {
TrackActionsSheet(
track = track,
hideQueueActions = hideQueueActions,
onDismiss = { show = false },
onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist,
)
}
}
@@ -0,0 +1,164 @@
package com.fabledsword.minstrel.shared.widgets.track_actions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Eye
import com.composables.icons.lucide.EyeOff
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.ListPlus
import com.composables.icons.lucide.ListVideo
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.TrackRef
/**
* The 7-item track-actions modal bottom sheet. Mirrors Flutter's
* `TrackActionsSheet`. Opens via [TrackActionsButton] (or any caller
* holding a `TrackRef`); closes via [onDismiss] after any item runs
* or when the user swipes down.
*
* The sheet pops first when an item is tapped (immediate visual
* feedback) and then runs the action. For Add to playlist… and Hide,
* the sub-sheet opens on top of this one; the parent stays mounted
* so the user can pop back out without losing context.
*
* [hideQueueActions] suppresses Play next + Add to queue — set true
* on the NowPlaying caller where the menu's track IS the playing one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrackActionsSheet(
track: TrackRef,
hideQueueActions: Boolean,
onDismiss: () -> Unit,
onNavigateToAlbum: (albumId: String) -> Unit,
onNavigateToArtist: (artistId: String) -> Unit,
viewModel: TrackActionsViewModel = hiltViewModel(),
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isLiked by viewModel.isLikedFlow(track.id).collectAsStateWithLifecycle(initialValue = false)
val isHidden by viewModel.isHiddenFlow(track.id).collectAsStateWithLifecycle(initialValue = false)
var showAddToPlaylist by remember { mutableStateOf(false) }
var showHideSheet by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
if (!hideQueueActions) {
MenuItem(Lucide.ListVideo, "Play next") {
viewModel.playNext(track)
onDismiss()
}
MenuItem(Lucide.ListMusic, "Add to queue") {
viewModel.enqueue(track)
onDismiss()
}
HorizontalDivider()
}
MenuItem(Lucide.Heart, if (isLiked) "Unlike" else "Like") {
viewModel.toggleLike(track.id, isLiked)
onDismiss()
}
MenuItem(Lucide.ListPlus, "Add to playlist…") {
showAddToPlaylist = true
}
MenuItem(Lucide.Radio, "Start radio") {
viewModel.startRadio(track.id)
onDismiss()
}
HorizontalDivider()
MenuItem(Lucide.Disc3, "Go to album") {
onDismiss()
if (track.albumId.isNotEmpty()) onNavigateToAlbum(track.albumId)
}
MenuItem(Lucide.User, "Go to artist") {
onDismiss()
if (track.artistId.isNotEmpty()) onNavigateToArtist(track.artistId)
}
HorizontalDivider()
MenuItem(
icon = if (isHidden) Lucide.Eye else Lucide.EyeOff,
label = if (isHidden) "Unhide" else "Hide",
) {
if (isHidden) {
viewModel.unflag(track.id)
onDismiss()
} else {
showHideSheet = true
}
}
}
}
if (showAddToPlaylist) {
AddToPlaylistSheet(
onPick = { playlistId ->
viewModel.appendToPlaylist(playlistId, track.id)
showAddToPlaylist = false
onDismiss()
},
onDismiss = { showAddToPlaylist = false },
)
}
if (showHideSheet) {
HideTrackSheet(
onSubmit = { reason, notes ->
viewModel.flag(track, reason, notes)
showHideSheet = false
onDismiss()
},
onDismiss = { showHideSheet = false },
)
}
}
@Composable
private fun MenuItem(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -0,0 +1,126 @@
package com.fabledsword.minstrel.shared.widgets.track_actions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Backs [TrackActionsSheet] with state observations + action callbacks.
* Scoped to the call-site screen via `hiltViewModel()`. The sheet hands
* a [TrackRef] into the action methods; the VM doesn't carry per-sheet
* trackId state — it exposes the flows + actions the sheet needs.
*
* Snackbar copy emits on [transientMessages] — call sites collect this
* and route to their existing SnackbarHostState.
*
* Hidden-state observation: [QuarantineRepository] doesn't yet expose
* a Flow for the caller's quarantine list (no Room cache; see the
* repository's class comment), so this VM holds its own snapshot of
* the hidden track-id set. Refreshed on init and after every flag /
* unflag action so the sheet reflects the current state next time
* it opens for the same track.
*/
@HiltViewModel
class TrackActionsViewModel @Inject constructor(
private val likes: LikesRepository,
private val quarantine: QuarantineRepository,
private val player: PlayerController,
private val playlists: PlaylistsRepository,
) : ViewModel() {
private val messages = MutableSharedFlow<String>(extraBufferCapacity = 4)
val transientMessages: SharedFlow<String> = messages.asSharedFlow()
private val hiddenTrackIdsInternal = MutableStateFlow<Set<String>>(emptySet())
val hiddenTrackIds: StateFlow<Set<String>> = hiddenTrackIdsInternal.asStateFlow()
init {
refreshHidden()
}
fun isLikedFlow(trackId: String): Flow<Boolean> =
likes.observeIsLiked(LikesRepository.ENTITY_TRACK, trackId)
fun isHiddenFlow(trackId: String): Flow<Boolean> =
hiddenTrackIds.map { trackId in it }
fun playNext(track: TrackRef) {
player.playNext(track)
messages.tryEmit("Added to queue (next)")
}
fun enqueue(track: TrackRef) {
player.enqueue(track)
messages.tryEmit("Added to queue")
}
fun toggleLike(trackId: String, currentlyLiked: Boolean) {
viewModelScope.launch {
runCatching {
likes.toggleLike(
entityType = LikesRepository.ENTITY_TRACK,
entityId = trackId,
desiredState = !currentlyLiked,
)
}
}
}
fun startRadio(trackId: String) {
viewModelScope.launch {
runCatching { player.startRadio(trackId) }
.onFailure { messages.tryEmit("Couldn't start radio: ${it.message ?: "unknown error"}") }
}
}
fun appendToPlaylist(playlistId: String, trackId: String) {
viewModelScope.launch {
runCatching { playlists.appendTrack(playlistId, trackId) }
.onSuccess { messages.tryEmit("Added to playlist") }
.onFailure { messages.tryEmit("Couldn't add to playlist: ${it.message ?: "unknown error"}") }
}
}
fun flag(track: TrackRef, reason: String, notes: String) {
viewModelScope.launch {
runCatching { quarantine.flag(track.id, reason, notes) }
.onSuccess { refreshHidden() }
.onFailure { messages.tryEmit("Couldn't hide: ${it.message ?: "unknown error"}") }
}
}
fun unflag(trackId: String) {
viewModelScope.launch {
runCatching { quarantine.unflag(trackId) }
.onSuccess { refreshHidden() }
.onFailure { messages.tryEmit("Couldn't unhide: ${it.message ?: "unknown error"}") }
}
}
private fun refreshHidden() {
viewModelScope.launch {
runCatching { quarantine.listMine() }
.onSuccess { rows ->
hiddenTrackIdsInternal.value = rows.map { it.trackId }.toSet()
}
// Failure leaves the previous snapshot; the sheet still
// shows "Hide" by default which is safe (server is
// idempotent on re-flag).
}
}
}