feat(player): heart/like button in queue view (web + android) — #1596
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:
@@ -1,11 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
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.PlayerController
|
||||||
import com.fabledsword.minstrel.player.PlayerUiState
|
import com.fabledsword.minstrel.player.PlayerUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,11 +27,26 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
|
private val likes: LikesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
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 play() = controller.play()
|
||||||
fun pause() = controller.pause()
|
fun pause() = controller.pause()
|
||||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
@@ -35,4 +55,13 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
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.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,6 +45,7 @@ fun QueueScreen(
|
|||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -67,7 +69,9 @@ fun QueueScreen(
|
|||||||
QueueList(
|
QueueList(
|
||||||
tracks = state.queue,
|
tracks = state.queue,
|
||||||
currentIndex = state.queueIndex,
|
currentIndex = state.queueIndex,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,14 +82,18 @@ fun QueueScreen(
|
|||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
QueueRow(
|
QueueRow(
|
||||||
track = track,
|
track = track,
|
||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
onClick = { onJumpTo(index) },
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
@@ -93,7 +101,13 @@ private fun QueueList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) {
|
val highlight = if (isCurrent) {
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
} else {
|
} else {
|
||||||
@@ -142,6 +156,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
||||||
import { offsetToDelta } from './queue-row-math';
|
import { offsetToDelta } from './queue-row-math';
|
||||||
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
let { track, index, isCurrent } = $props<{
|
let { track, index, isCurrent } = $props<{
|
||||||
track: TrackRef;
|
track: TrackRef;
|
||||||
@@ -80,6 +81,8 @@
|
|||||||
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
|
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<LikeButton entityType="track" entityId={track.id} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleRemove}
|
onclick={handleRemove}
|
||||||
|
|||||||
Reference in New Issue
Block a user