Player: unify local+UPnP behind one authoritative cursor (#171) + queue heart button (#1596) #110

Merged
bvandeusen merged 4 commits from dev into main 2026-07-15 16:30:52 -04:00
3 changed files with 48 additions and 1 deletions
Showing only changes of commit 235839b696 - Show all commits
@@ -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}