Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a26ef4e93c | |||
| 0774f5f55f | |||
| 509cbe79b2 | |||
| dc7b9b78fa | |||
| cde74b5965 | |||
| 0efbf5fcaa | |||
| 2038028d42 | |||
| 723293110d | |||
| 41ebf1405b | |||
| f2dcf2596d | |||
| 47de7be472 | |||
| 659554df0e | |||
| 2ecdd46a2b | |||
| 41fe76b90c | |||
| 6912dadf2b | |||
| 304e06acc8 | |||
| 235839b696 |
+106
-24
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
|||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||||
* 1-based Track index from GetPositionInfo.
|
* track the renderer is actually playing, matched by track identity
|
||||||
|
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||||
|
* index drift -- see [syncLocalCursorToRemote].
|
||||||
*/
|
*/
|
||||||
class MinstrelForwardingPlayer(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||||
|
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||||
|
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||||
|
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||||
|
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||||
|
// the notification next/prev only nudged the local cursor (which the poll
|
||||||
|
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||||
|
// Route them through the same Sonos path as the media-item variants when a
|
||||||
|
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||||
|
override fun seekToNext() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCurrentPosition(): Long =
|
override fun getCurrentPosition(): Long =
|
||||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||||
|
|
||||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and sync the local cursor to the track the renderer is
|
||||||
* index when not in queue load.
|
* actually playing (see [syncLocalCursorToRemote]).
|
||||||
*
|
|
||||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
|
||||||
* because during load Sonos reports Track=1 while we're still
|
|
||||||
* appending, and syncing would race the SetAV+Seek that lands
|
|
||||||
* after. Outside load, forward sync catches Sonos auto-advances
|
|
||||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
|
||||||
* drift after a brief poll-failure burst that didn't trip the
|
|
||||||
* drop threshold. Forward-only because a Next override we just
|
|
||||||
* issued can race with a poll still reporting the prior Track --
|
|
||||||
* the next poll catches up safely.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
maybeSyncLocalCursor(info.track)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
/**
|
||||||
if (holder.target.value != null) return
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
if (sonosTrack <= 0) return
|
* actually playing, so the un-overridden current-item getters
|
||||||
val sonosIdx = sonosTrack - 1
|
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||||
|
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||||
|
* resolve to that track. This is THE single source of truth for "what's
|
||||||
|
* playing" during a cast.
|
||||||
|
*
|
||||||
|
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||||
|
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||||
|
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||||
|
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||||
|
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||||
|
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||||
|
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||||
|
* too. Runs on the application looper (delegate access contract).
|
||||||
|
*
|
||||||
|
* Suppressed while:
|
||||||
|
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||||
|
* (re)uploaded, so its Track index is meaningless; and
|
||||||
|
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||||
|
* the override already advanced the delegate optimistically, and Sonos
|
||||||
|
* still reports the OLD track for a beat, so syncing now would undo the
|
||||||
|
* user's press. The pending deadline is honoured directly so a missed
|
||||||
|
* clear can't wedge the sync.
|
||||||
|
*/
|
||||||
|
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||||
|
if (isLoadingUpnp()) return
|
||||||
|
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||||
|
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||||
handler.post {
|
handler.post {
|
||||||
val localIdx = delegate.currentMediaItemIndex
|
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
if (target != delegate.currentMediaItemIndex) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
"UPnP cursor sync: local=%d -> %d",
|
||||||
localIdx, sonosIdx,
|
delegate.currentMediaItemIndex, target,
|
||||||
)
|
)
|
||||||
delegate.seekTo(sonosIdx, 0L)
|
delegate.seekTo(target, 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||||
|
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||||
|
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||||
|
* Must run on the application looper -- reads the delegate timeline.
|
||||||
|
*/
|
||||||
|
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||||
|
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||||
|
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||||
|
}
|
||||||
|
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||||
|
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||||
|
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||||
|
*/
|
||||||
|
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var bestDist = Int.MAX_VALUE
|
||||||
|
for (i in 0 until delegate.mediaItemCount) {
|
||||||
|
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||||
|
val dist = abs(i - preferNear)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
best = i
|
||||||
|
bestDist = dist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||||
|
private fun trackIdFromStreamUri(uri: String): String? {
|
||||||
|
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||||
|
if (start < 0) return null
|
||||||
|
val idStart = start + TRACKS_PATH_MARKER.length
|
||||||
|
val idEnd = uri.indexOf('/', idStart)
|
||||||
|
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
// Path segment that precedes the track-id in a stream URI
|
||||||
|
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||||
|
// back to a delegate MediaItem by identity.
|
||||||
|
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
controller.addMediaItem(track.toMediaItem(source = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||||
|
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||||
|
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||||
|
* index). No-op on bad indices or a no-move.
|
||||||
|
*/
|
||||||
|
fun moveInQueue(from: Int, to: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
controller.moveMediaItem(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the queue item at [index]. When it's the currently-playing item
|
||||||
|
* Media3 advances to the next automatically. No-op on a bad index.
|
||||||
|
*/
|
||||||
|
fun removeFromQueue(index: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||||
|
controller.removeMediaItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the queue and stop playback. */
|
||||||
|
fun clearQueue() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
queueRefs = emptyList()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
* queue index + current track are owned solely by [onEvents], which reads
|
||||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
* the authoritative delegate cursor: during a cast,
|
||||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
* the track the renderer is actually playing (matched by identity), so there
|
||||||
* wrapped player as defense in depth.
|
* is exactly one writer of "what's playing" and a stale tick can never
|
||||||
|
* revert it to the old track. (Previously the tick was a second index writer
|
||||||
|
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||||
*/
|
*/
|
||||||
private fun tickPositionPoll(controller: MediaController) {
|
private fun tickPositionPoll(controller: MediaController) {
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
val upnpActive = activeUpnpHolder.active.value != null
|
||||||
resolvePendingTransport(controller, upnpActive)
|
resolvePendingTransport(controller, upnpActive)
|
||||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition = if (upnpActive) {
|
val effectivePosition = if (upnpActive) {
|
||||||
@@ -617,28 +649,30 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
val idx = controller.currentMediaItemIndex
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
val newDur = effectiveDuration(
|
||||||
upnpActive,
|
upnpActive,
|
||||||
remoteState.durationMs,
|
remoteState.durationMs,
|
||||||
controller.duration,
|
controller.duration,
|
||||||
desiredIdx = desiredIdx,
|
desiredIdx = idx,
|
||||||
controllerIdx = controller.currentMediaItemIndex,
|
controllerIdx = idx,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
// Track adjustments are forward-only AND suppressed while a user
|
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||||
// transport press is pending Sonos confirmation. Together those keep
|
current.positionMs != newPos ||
|
||||||
// either direction of user input from being undone by a stale poll.
|
current.durationMs != newDur ||
|
||||||
val trackChanged = !pendingTransport &&
|
current.bufferedPositionMs != newBuf
|
||||||
desiredIdx > current.queueIndex &&
|
if (somethingChanged) {
|
||||||
desiredIdx in queueRefs.indices
|
uiStateInternal.value = current.copy(
|
||||||
publishTickIfChanged(
|
isPlaying = effectiveIsPlaying,
|
||||||
current, trackChanged, desiredIdx,
|
positionMs = newPos,
|
||||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
durationMs = newDur,
|
||||||
|
bufferedPositionMs = newBuf,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event-driven primary path: clear pending the moment Sonos's reported
|
* Event-driven primary path: clear pending the moment Sonos's reported
|
||||||
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
|
||||||
private fun publishTickIfChanged(
|
|
||||||
current: PlayerUiState,
|
|
||||||
trackChanged: Boolean,
|
|
||||||
desiredIdx: Int,
|
|
||||||
effectiveIsPlaying: Boolean,
|
|
||||||
newPos: Long,
|
|
||||||
newDur: Long,
|
|
||||||
newBuf: Long,
|
|
||||||
) {
|
|
||||||
val somethingChanged = trackChanged ||
|
|
||||||
current.isPlaying != effectiveIsPlaying ||
|
|
||||||
current.positionMs != newPos ||
|
|
||||||
current.durationMs != newDur
|
|
||||||
if (!somethingChanged) return
|
|
||||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
|
||||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
|
||||||
uiStateInternal.value = current.copy(
|
|
||||||
currentTrack = newTrack,
|
|
||||||
queueIndex = newIdx,
|
|
||||||
isPlaying = effectiveIsPlaying,
|
|
||||||
positionMs = newPos,
|
|
||||||
durationMs = newDur,
|
|
||||||
bufferedPositionMs = newBuf,
|
|
||||||
)
|
|
||||||
// Intentionally do NOT call controller.seekTo here. That would route
|
|
||||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
|
||||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
|
||||||
// start of the same track it's already playing, restarting the song.
|
|
||||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
|
||||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
|
||||||
// the next pollOnce catches up; the uiState above already reflects
|
|
||||||
// Sonos's truth for the user.
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
|
||||||
if (upnpActive) {
|
|
||||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
|
||||||
} else {
|
|
||||||
controller.currentMediaItemIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote position interpolation state ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
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 +56,16 @@ 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 moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||||
|
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||||
|
fun clearQueue() = controller.clearQueue()
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
|
|||||||
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.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.GripVertical
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
|
import com.composables.icons.lucide.Trash2
|
||||||
import com.composables.icons.lucide.Volume2
|
import com.composables.icons.lucide.Volume2
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
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
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,16 +73,35 @@ 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 = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Queue") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Queue")
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = queueSummary(state.queue),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -67,33 +115,109 @@ 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,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
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,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
|
)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
|
LaunchedEffect(currentIndex) {
|
||||||
|
if (currentIndex < 0) return@LaunchedEffect
|
||||||
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
|
val first = visible.firstOrNull()?.index ?: 0
|
||||||
|
val last = visible.lastOrNull()?.index ?: 0
|
||||||
|
if (currentIndex in (first - 1)..(last + 1)) {
|
||||||
|
listState.animateScrollToItem(currentIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(state = listState, 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,
|
||||||
|
index = index,
|
||||||
|
queueSize = tracks.size,
|
||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
onClick = { onJumpTo(index) },
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
|
onRemove = { onRemove(index) },
|
||||||
|
onMove = onMove,
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
JumpToCurrentPill(
|
||||||
|
visible = currentIndex >= 0 && !currentVisible,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
private fun JumpToCurrentPill(
|
||||||
|
visible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
|
FilledTonalButton(onClick = onClick) {
|
||||||
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Jump to current")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
private fun QueueRow(
|
||||||
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
liked: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleLike: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||||
val highlight = if (isCurrent) {
|
val highlight = if (isCurrent) {
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
} else {
|
} else {
|
||||||
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { rowHeightPx = it.height }
|
||||||
|
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = dragOffsetY }
|
||||||
.background(highlight)
|
.background(highlight)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
DragHandle(
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onOffsetChange = { dragOffsetY = it },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
QueueRowThumbnail(track = track)
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Icon(
|
Icon(
|
||||||
Lucide.Volume2,
|
Lucide.Volume2,
|
||||||
@@ -115,7 +250,85 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||||
|
if (track.durationSec > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(track.durationSec),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Lucide.X, contentDescription = "Remove from queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DragHandle(
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onOffsetChange: (Float) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||||
|
// release we translate the accumulated offset into a row delta and reorder.
|
||||||
|
var offset by remember { mutableFloatStateOf(0f) }
|
||||||
|
Icon(
|
||||||
|
Lucide.GripVertical,
|
||||||
|
contentDescription = "Reorder track",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offset += dragAmount.y
|
||||||
|
onOffsetChange(offset)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||||
|
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||||
|
if (target != index) onMove(index, target)
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowThumbnail(track: TrackRef) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
ServerImage(
|
||||||
|
url = track.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
@@ -135,14 +348,6 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (track.durationSec > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatDuration(track.durationSec),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||||
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
|||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString(" · ")
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
/** "N tracks · 12 min" header summary. */
|
||||||
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
|
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||||
|
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||||
|
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||||
|
} else {
|
||||||
|
"$minutes min"
|
||||||
|
}
|
||||||
|
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||||
|
return "${tracks.size} $noun · $length"
|
||||||
|
}
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
@@ -35,5 +35,9 @@
|
|||||||
transition-transform duration-200
|
transition-transform duration-200
|
||||||
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
||||||
>
|
>
|
||||||
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
|
<QueueList
|
||||||
|
onClose={() => closeQueueDrawer()}
|
||||||
|
active={player.queueDrawerOpen}
|
||||||
|
bind:closeButtonRef={closeButton}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import QueueDrawer from './QueueDrawer.svelte';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '$test-utils/fixtures/track';
|
||||||
|
|
||||||
|
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
|
||||||
|
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
|
||||||
|
// rows don't need a real QueryClient in context. Both mocks — and the
|
||||||
|
// emptyLikesMock import — must precede the QueueDrawer import below, since
|
||||||
|
// loading the component evaluates the mocked modules and the hoisted
|
||||||
|
// factories need their referenced bindings already initialized.
|
||||||
|
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||||
|
|
||||||
const closeQueueDrawer = vi.fn();
|
const closeQueueDrawer = vi.fn();
|
||||||
let queueValue: TrackRef[] = [];
|
let queueValue: TrackRef[] = [];
|
||||||
let indexValue = 0;
|
let indexValue = 0;
|
||||||
@@ -17,12 +25,16 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
get queueDrawerOpen() { return openValue; }
|
get queueDrawerOpen() { return openValue; }
|
||||||
},
|
},
|
||||||
// QueueTrackRow imports these from the store; provide stubs so its
|
// QueueTrackRow imports these from the store; provide stubs so its
|
||||||
// module-load doesn't break when QueueDrawer renders rows.
|
// module-load doesn't break when QueueDrawer renders rows. QueueList
|
||||||
|
// imports clearQueue for its header action.
|
||||||
playFromQueueIndex: vi.fn(),
|
playFromQueueIndex: vi.fn(),
|
||||||
removeFromQueue: vi.fn(),
|
removeFromQueue: vi.fn(),
|
||||||
moveQueueItem: vi.fn()
|
moveQueueItem: vi.fn(),
|
||||||
|
clearQueue: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import QueueDrawer from './QueueDrawer.svelte';
|
||||||
|
|
||||||
const t = (id: string): TrackRef => makeTrack({ id, title: `Song ${id}` });
|
const t = (id: string): TrackRef => makeTrack({ id, title: `Song ${id}` });
|
||||||
|
|
||||||
describe('QueueDrawer', () => {
|
describe('QueueDrawer', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from 'lucide-svelte';
|
import { untrack } from 'svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
||||||
|
import { player, clearQueue } from '$lib/player/store.svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
// onClose: when provided, renders an X button in the header so the
|
// onClose: when provided, renders an X button in the header so the
|
||||||
@@ -8,12 +9,66 @@
|
|||||||
// now-playing route (visible at lg+ widths) omits it.
|
// now-playing route (visible at lg+ widths) omits it.
|
||||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||||
// keyboard users on open.
|
// keyboard users on open.
|
||||||
|
// active: true when the queue is on-screen (drawer open, or the always-
|
||||||
|
// visible now-playing panel). Gates the scroll-to-current behavior.
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeButtonRef?: HTMLButtonElement;
|
closeButtonRef?: HTMLButtonElement;
|
||||||
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onClose, closeButtonRef = $bindable() }: Props = $props();
|
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
||||||
|
|
||||||
|
let scrollBody: HTMLElement | undefined = $state();
|
||||||
|
// Whether the now-playing row is (at least partly) within the scroll
|
||||||
|
// viewport. Drives auto-follow (only follow while the user is watching the
|
||||||
|
// current track) and the "Jump to current" pill (shown when it's off-screen).
|
||||||
|
let currentInView = $state(true);
|
||||||
|
let sawFirstIndex = false;
|
||||||
|
|
||||||
|
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
|
||||||
|
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
|
||||||
|
block,
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
currentInView = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeInView() {
|
||||||
|
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
|
||||||
|
if (!scrollBody || !row) {
|
||||||
|
currentInView = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = scrollBody.getBoundingClientRect();
|
||||||
|
const r = row.getBoundingClientRect();
|
||||||
|
currentInView = r.bottom > b.top && r.top < b.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On open (active flips true, or on mount for the always-visible panel),
|
||||||
|
// center the now-playing row — parity with the Android queue.
|
||||||
|
$effect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
if (untrack(() => player.queue.length) === 0) return;
|
||||||
|
requestAnimationFrame(() => scrollToCurrent('center'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Follow the current track as it auto-advances, but only while the user is
|
||||||
|
// still watching it — if they've scrolled away, leave them there (the pill
|
||||||
|
// offers the way back). block:'nearest' keeps it minimal (no yank when the
|
||||||
|
// row is already visible). Index is tracked; currentInView is read untracked
|
||||||
|
// so a scroll that hides the row doesn't itself re-trigger a scroll.
|
||||||
|
$effect(() => {
|
||||||
|
player.index; // subscribe: follow on advance
|
||||||
|
if (!sawFirstIndex) {
|
||||||
|
sawFirstIndex = true;
|
||||||
|
return; // the open effect already handled the initial position
|
||||||
|
}
|
||||||
|
if (!active) return;
|
||||||
|
if (untrack(() => player.queue.length) === 0) return;
|
||||||
|
if (!untrack(() => currentInView)) return;
|
||||||
|
requestAnimationFrame(() => scrollToCurrent('nearest'));
|
||||||
|
});
|
||||||
|
|
||||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||||
@@ -23,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="relative flex h-full flex-col">
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Queue</h2>
|
<h2 class="text-lg font-semibold">Queue</h2>
|
||||||
@@ -32,20 +87,33 @@
|
|||||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if player.queue.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear queue"
|
||||||
|
title="Clear queue"
|
||||||
|
onclick={() => clearQueue()}
|
||||||
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if onClose}
|
{#if onClose}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bind:this={closeButtonRef}
|
bind:this={closeButtonRef}
|
||||||
aria-label="Close queue"
|
aria-label="Close queue"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="text-text-secondary hover:text-text-primary"
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
|
||||||
{#if player.queue.length === 0}
|
{#if player.queue.length === 0}
|
||||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -54,4 +122,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if active && player.queue.length > 0 && !currentInView}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => scrollToCurrent('center', 'smooth')}
|
||||||
|
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
|
||||||
|
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
|
||||||
|
text-action-fg shadow-lg"
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
Jump to current
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
import { draggable, type DragEventData } from '@neodrag/svelte';
|
||||||
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 { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
|
||||||
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;
|
||||||
@@ -65,6 +67,13 @@
|
|||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={coverUrl(track.album_id)}
|
||||||
|
alt=""
|
||||||
|
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||||
|
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleBodyClick}
|
onclick={handleBodyClick}
|
||||||
@@ -80,6 +89,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}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '$test-utils/fixtures/track';
|
||||||
|
|
||||||
|
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
|
||||||
|
// Stub the likes API so the row doesn't need a real QueryClient in context.
|
||||||
|
// The mock (and its emptyLikesMock import) must precede the component import
|
||||||
|
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
|
||||||
|
// module, and the hoisted factory needs emptyLikesMock already initialized.
|
||||||
|
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||||
|
|
||||||
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
const playFromQueueIndex = vi.fn();
|
const playFromQueueIndex = vi.fn();
|
||||||
const removeFromQueue = vi.fn();
|
const removeFromQueue = vi.fn();
|
||||||
const moveQueueItem = vi.fn();
|
const moveQueueItem = vi.fn();
|
||||||
|
|||||||
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
|
|||||||
_error = null;
|
_error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the whole queue and stop playback — mirrors removeFromQueue's
|
||||||
|
// empty-queue branch. Also drops the radio/system source + self-heal closure
|
||||||
|
// so the emptied player doesn't try to refill from a now-irrelevant source.
|
||||||
|
export function clearQueue(): void {
|
||||||
|
_queue = [];
|
||||||
|
_index = 0;
|
||||||
|
_state = 'idle';
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_error = null;
|
||||||
|
_radioSeedId = null;
|
||||||
|
_queueSource = null;
|
||||||
|
_queueRefetch = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function playFromQueueIndex(idx: number): void {
|
export function playFromQueueIndex(idx: number): void {
|
||||||
if (idx < 0 || idx >= _queue.length) return;
|
if (idx < 0 || idx >= _queue.length) return;
|
||||||
_radioSeedId = null;
|
_radioSeedId = null;
|
||||||
|
|||||||
@@ -168,9 +168,14 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
></audio>
|
></audio>
|
||||||
|
|
||||||
<QueueDrawer />
|
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
|
||||||
|
which calls useQueryClient() at init. The drawer's <aside> is always
|
||||||
|
mounted, so the moment the queue is populated (on first play) those
|
||||||
|
LikeButtons instantiate — outside the provider they throw
|
||||||
|
"No QueryClient was found" and abort the play flush. -->
|
||||||
|
<QueueDrawer />
|
||||||
|
|
||||||
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
||||||
<Shell>{@render children()}</Shell>
|
<Shell>{@render children()}</Shell>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
|
|||||||
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
|
||||||
|
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
|
||||||
|
// would throw an unhandled TypeError in tests — which fails the run even when
|
||||||
|
// every assertion passes. No-op it; tests never assert on scroll position.
|
||||||
|
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
// W-T3 moved toast rendering out of per-page markup into a single
|
// W-T3 moved toast rendering out of per-page markup into a single
|
||||||
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
||||||
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
||||||
|
|||||||
Reference in New Issue
Block a user