feat(player): heart/like button in queue view (web + android) — #1596
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 4m25s

The full-screen player's queue ("up next") track rows were the one
track-list surface missing the like heart that TrackRow/PlaylistTrackRow
(web) and playlist/album/artist detail (Android) already carried.

Web: render the shared <LikeButton> in QueueTrackRow between the row body
and the remove button (serves both the /now-playing aside and the mobile
QueueDrawer, same component). LikeButton already stops click propagation
so it won't trigger play-on-click.

Android: PlayerViewModel now exposes likedTrackIds (set-based, the same
idiom as the detail VMs) + toggleLikeTrack; QueueScreen threads
liked/onToggleLike through QueueList → QueueRow, which renders the shared
LikeButton after the duration. Liked state stays sourced from
LikesRepository by track.id — no TrackRef data-model change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:02:32 -04:00
parent 5749f48b4a
commit 235839b696
3 changed files with 48 additions and 1 deletions
@@ -1,11 +1,16 @@
package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -22,11 +27,26 @@ import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
/**
* Reactive set of liked track ids — the set-based pattern the queue
* (and playlist/album/artist detail) uses to color each row's
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
* per-row Flow subscription.
*/
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
fun play() = controller.play()
fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -35,4 +55,13 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat()
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
}
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -36,6 +36,7 @@ import com.composables.icons.lucide.Volume2
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -44,6 +45,7 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
@@ -67,7 +69,9 @@ fun QueueScreen(
QueueList(
tracks = state.queue,
currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
)
}
}
@@ -78,14 +82,18 @@ fun QueueScreen(
private fun QueueList(
tracks: List<TrackRef>,
currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
)
HorizontalDivider()
}
@@ -93,7 +101,13 @@ private fun QueueList(
}
@Composable
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
private fun QueueRow(
track: TrackRef,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
@@ -142,6 +156,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LikeButton(liked = liked, onToggle = onToggleLike)
}
}
@@ -4,6 +4,7 @@
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { offsetToDelta } from './queue-row-math';
import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{
track: TrackRef;
@@ -80,6 +81,8 @@
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
</button>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
onclick={handleRemove}