Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
47de7be472 | ||
|
|
2ecdd46a2b | ||
|
|
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 |
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
||||
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
|
||||
* "radio:<id>" so the server-side rotation reporter can
|
||||
|
||||
@@ -25,6 +25,7 @@ import javax.inject.Inject
|
||||
* stub-test for ViewModel-level logic when it grows).
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val controller: PlayerController,
|
||||
private val likes: LikesRepository,
|
||||
@@ -55,6 +56,9 @@ class PlayerViewModel @Inject constructor(
|
||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||
fun toggleShuffle() = controller.toggleShuffle()
|
||||
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
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -21,23 +28,43 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowDown
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.GripVertical
|
||||
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.X
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -51,12 +78,30 @@ fun QueueScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
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 = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.queue.isNotEmpty()) {
|
||||
IconButton(onClick = viewModel::clearQueue) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
@@ -73,12 +118,15 @@ fun QueueScreen(
|
||||
likedTrackIds = likedTrackIds,
|
||||
onJumpTo = viewModel::seekToIndex,
|
||||
onToggleLike = viewModel::toggleLikeTrack,
|
||||
onMove = viewModel::moveInQueue,
|
||||
onRemove = viewModel::removeFromQueue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueList(
|
||||
tracks: List<TrackRef>,
|
||||
@@ -86,36 +134,90 @@ private fun QueueList(
|
||||
likedTrackIds: Set<String>,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleLike: (String) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
) {
|
||||
// Open scrolled to the now-playing track so it's in view immediately.
|
||||
// Seeding the initial index (rather than animating post-layout) avoids a
|
||||
// flash of the list top; it's captured once per entry, so the view stays
|
||||
// put as the track later auto-advances — matching "show me where I am now."
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||
)
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
isCurrent = index == currentIndex,
|
||||
liked = track.id in likedTrackIds,
|
||||
onClick = { onJumpTo(index) },
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
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 ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = tracks.size,
|
||||
isCurrent = index == currentIndex,
|
||||
liked = track.id in likedTrackIds,
|
||||
onClick = { onJumpTo(index) },
|
||||
onToggleLike = { onToggleLike(track.id) },
|
||||
onRemove = { onRemove(index) },
|
||||
onMove = onMove,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
JumpToCurrentPill(
|
||||
visible = currentIndex >= 0 && !currentVisible,
|
||||
onClick = {
|
||||
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
@@ -124,12 +226,23 @@ private fun QueueRow(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY }
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
DragHandle(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = { dragOffsetY = it },
|
||||
onMove = onMove,
|
||||
)
|
||||
QueueRowThumbnail(track = track)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
@@ -137,26 +250,7 @@ private fun QueueRow(
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
@@ -165,6 +259,94 @@ private fun QueueRow(
|
||||
)
|
||||
}
|
||||
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 = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
||||
.filter { it.isNotEmpty() }
|
||||
.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 SECONDS_PER_MINUTE = 60
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,10 +25,12 @@ vi.mock('$lib/player/store.svelte', () => ({
|
||||
get queueDrawerOpen() { return openValue; }
|
||||
},
|
||||
// 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(),
|
||||
removeFromQueue: vi.fn(),
|
||||
moveQueueItem: vi.fn()
|
||||
moveQueueItem: vi.fn(),
|
||||
clearQueue: vi.fn()
|
||||
}));
|
||||
|
||||
import QueueDrawer from './QueueDrawer.svelte';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { X } from 'lucide-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';
|
||||
|
||||
// onClose: when provided, renders an X button in the header so the
|
||||
@@ -10,9 +10,7 @@
|
||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||
// keyboard users on open.
|
||||
// active: true when the queue is on-screen (drawer open, or the always-
|
||||
// visible now-playing panel). Flipping it true scrolls the now-playing
|
||||
// row into view — parity with the Android queue, which opens positioned
|
||||
// on the current track.
|
||||
// visible now-playing panel). Gates the scroll-to-current behavior.
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
closeButtonRef?: HTMLButtonElement;
|
||||
@@ -22,19 +20,54 @@
|
||||
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;
|
||||
|
||||
// When the queue becomes visible, center the now-playing row in view. The
|
||||
// index/length are read untracked so this fires once per open (matching the
|
||||
// Android queue's open-positioned behavior) rather than following the track
|
||||
// as it auto-advances. Deferred a frame so the drawer's slide-in has settled.
|
||||
$effect(() => {
|
||||
if (!active || !scrollBody) return;
|
||||
const body = scrollBody;
|
||||
const index = untrack(() => player.index);
|
||||
if (untrack(() => player.queue.length) === 0) return;
|
||||
requestAnimationFrame(() => {
|
||||
(body.children[index] as HTMLElement | undefined)?.scrollIntoView({ block: 'center' });
|
||||
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 {
|
||||
@@ -45,7 +78,7 @@
|
||||
}
|
||||
</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>
|
||||
<h2 class="text-lg font-semibold">Queue</h2>
|
||||
@@ -54,20 +87,33 @@
|
||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if onClose}
|
||||
<button
|
||||
type="button"
|
||||
bind:this={closeButtonRef}
|
||||
aria-label="Close queue"
|
||||
onclick={onClose}
|
||||
class="text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
bind:this={closeButtonRef}
|
||||
aria-label="Close queue"
|
||||
onclick={onClose}
|
||||
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div bind:this={scrollBody} class="flex-1 overflow-y-auto">
|
||||
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
|
||||
{#if player.queue.length === 0}
|
||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||
{:else}
|
||||
@@ -76,4 +122,17 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
||||
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { offsetToDelta } from './queue-row-math';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
|
||||
@@ -66,6 +67,13 @@
|
||||
<GripVertical size={16} />
|
||||
</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
|
||||
type="button"
|
||||
onclick={handleBodyClick}
|
||||
|
||||
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
|
||||
_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 {
|
||||
if (idx < 0 || idx >= _queue.length) return;
|
||||
_radioSeedId = null;
|
||||
|
||||
Reference in New Issue
Block a user