Compare commits
79 Commits
dev
..
v2026.07.14
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f69c230c4 | |||
| 725ddca950 | |||
| d145fee35d | |||
| 611715154b | |||
| 23a82fb38d | |||
| 0de2437689 | |||
| a251dce7e3 | |||
| 938dae7163 | |||
| 93365cb555 | |||
| eeabdf1f2c | |||
| 3e258507bb | |||
| 9550d8daaf | |||
| db393bbe65 | |||
| 7b7bd0c3e8 | |||
| 8019537b02 | |||
| e41d603c12 | |||
| a62f07bd3a | |||
| 3c646c6974 | |||
| 9a57dc4bec | |||
| f167ddfbfb | |||
| 962b4dbc8c | |||
| aa4089118e | |||
| 7c791dc8e4 | |||
| 11466e1525 | |||
| 301c3bfb86 | |||
| 4d8c7d6566 | |||
| d6e6caa223 | |||
| 8b08482d13 | |||
| 7cf04fe24b | |||
| 222a0ff636 | |||
| d75c1ae37f | |||
| 3c4c27fb08 | |||
| a62a20b599 | |||
| d9b2dd957c | |||
| 46dcd38fd8 | |||
| 7838038047 | |||
| b64965b38d | |||
| bf2f9f3811 | |||
| deb726a285 | |||
| 7ede83a586 | |||
| 1d7b91333f | |||
| 883d416d26 | |||
| 09471a8f5c | |||
| 7de238e91e | |||
| 9440c5860b | |||
| 082d31bfa9 | |||
| 8847b43d9e | |||
| 09cc810e5a | |||
| 2534384ed1 | |||
| 9ff1b30e3f | |||
| c01853577b | |||
| 747ed4134b | |||
| ccbd3b62a0 | |||
| 19de0c2874 | |||
| 22a4649bfc | |||
| 8e7660c05e | |||
| 53be834e89 | |||
| 0d410630a2 | |||
| 62db8edcdb | |||
| ec0cc37bc9 | |||
| e772938a3b | |||
| 29fee5aa37 | |||
| e5ab471ce1 | |||
| 573aa4226d | |||
| 7339815ea9 | |||
| baa601765e | |||
| 0d009b34e2 | |||
| f1b4652c77 | |||
| e610948307 | |||
| fb811804d2 | |||
| 37134950a5 | |||
| fcded9294c | |||
| 42abb7adff | |||
| 04933f2d9f | |||
| 626fc7502c | |||
| 9bf3b8a2f2 | |||
| 492460cf4a | |||
| 1c775905d7 | |||
| cd2ff648d0 |
+24
-89
@@ -17,7 +17,6 @@ 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
|
||||||
@@ -59,10 +58,8 @@ 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 keeps the local cursor aligned to the
|
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||||
* track the renderer is actually playing, matched by track identity
|
* 1-based Track index from GetPositionInfo.
|
||||||
* (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,
|
||||||
@@ -529,8 +526,18 @@ 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 sync the local cursor to the track the renderer is
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
* actually playing (see [syncLocalCursorToRemote]).
|
* index when not in queue load.
|
||||||
|
*
|
||||||
|
* 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()
|
||||||
@@ -546,7 +553,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
maybeSyncLocalCursor(info.track)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -570,95 +577,23 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||||
* Align the paused local delegate cursor to the track the renderer is
|
if (holder.target.value != null) return
|
||||||
* actually playing, so the un-overridden current-item getters
|
if (sonosTrack <= 0) return
|
||||||
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
val sonosIdx = sonosTrack - 1
|
||||||
* 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 target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
val localIdx = delegate.currentMediaItemIndex
|
||||||
if (target != delegate.currentMediaItemIndex) {
|
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor sync: local=%d -> %d",
|
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||||
delegate.currentMediaItemIndex, target,
|
localIdx, sonosIdx,
|
||||||
)
|
)
|
||||||
delegate.seekTo(target, 0L)
|
delegate.seekTo(sonosIdx, 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
|
||||||
|
|||||||
@@ -598,19 +598,18 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
* One position-polling tick. Owns track-change detection too: when UPnP
|
||||||
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||||
* queue index + current track are owned solely by [onEvents], which reads
|
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||||
* the authoritative delegate cursor: during a cast,
|
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||||
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||||
* the track the renderer is actually playing (matched by identity), so there
|
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||||
* is exactly one writer of "what's playing" and a stale tick can never
|
* wrapped player as defense in depth.
|
||||||
* 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) {
|
||||||
@@ -618,29 +617,27 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val idx = controller.currentMediaItemIndex
|
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||||
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 = idx,
|
desiredIdx = desiredIdx,
|
||||||
controllerIdx = idx,
|
controllerIdx = controller.currentMediaItemIndex,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
// Track adjustments are forward-only AND suppressed while a user
|
||||||
current.positionMs != newPos ||
|
// transport press is pending Sonos confirmation. Together those keep
|
||||||
current.durationMs != newDur ||
|
// either direction of user input from being undone by a stale poll.
|
||||||
current.bufferedPositionMs != newBuf
|
val trackChanged = !pendingTransport &&
|
||||||
if (somethingChanged) {
|
desiredIdx > current.queueIndex &&
|
||||||
uiStateInternal.value = current.copy(
|
desiredIdx in queueRefs.indices
|
||||||
isPlaying = effectiveIsPlaying,
|
publishTickIfChanged(
|
||||||
positionMs = newPos,
|
current, trackChanged, desiredIdx,
|
||||||
durationMs = newDur,
|
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||||
bufferedPositionMs = newBuf,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -657,6 +654,48 @@ 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,16 +1,11 @@
|
|||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,26 +22,11 @@ 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)
|
||||||
@@ -55,13 +35,4 @@ 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,7 +36,6 @@ 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
|
||||||
@@ -45,7 +44,6 @@ 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 = {
|
||||||
@@ -69,9 +67,7 @@ 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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,18 +78,14 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -101,13 +93,7 @@ private fun QueueList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(
|
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||||
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 {
|
||||||
@@ -156,7 +142,6 @@ private fun QueueRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":semanticCommits"
|
||||||
|
],
|
||||||
|
"baseBranches": ["dev"],
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"schedule": ["every weekend"],
|
||||||
|
"prHourlyLimit": 2,
|
||||||
|
"prConcurrentLimit": 8,
|
||||||
|
"ignorePaths": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"flutter_client/**"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 5am on the first day of the month"]
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"automerge": false,
|
||||||
|
"dependencyDashboardApproval": true,
|
||||||
|
"addLabels": ["deps", "deps:major"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Go module updates into one PR",
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"groupName": "go modules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group CI workflow action bumps",
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"groupName": "ci actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||||
|
"matchManagers": ["dockerfile", "docker-compose"],
|
||||||
|
"groupName": "docker images"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||||
|
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||||
|
"groupName": "android gradle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group web npm non-major bumps",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "web npm (non-major)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
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 { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import QueueDrawer from './QueueDrawer.svelte';
|
||||||
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;
|
||||||
@@ -31,8 +23,6 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
moveQueueItem: vi.fn()
|
moveQueueItem: 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', () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
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;
|
||||||
@@ -81,8 +80,6 @@
|
|||||||
<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,16 +1,7 @@
|
|||||||
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 { emptyLikesMock } from '../../test-utils/mocks/likes';
|
|
||||||
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';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
import { makeTrack } from '$test-utils/fixtures/track';
|
||||||
|
|
||||||
const playFromQueueIndex = vi.fn();
|
const playFromQueueIndex = vi.fn();
|
||||||
const removeFromQueue = vi.fn();
|
const removeFromQueue = vi.fn();
|
||||||
|
|||||||
Reference in New Issue
Block a user