Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b6f22d86 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
e1e591b520 |
@@ -8,7 +8,16 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
grants the silent path only when the installer opts in via
|
||||
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
|
||||
installed app targets API 29+, the installer holds this permission, and
|
||||
the target is the installer itself — all true here, since Minstrel is
|
||||
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
@@ -48,15 +57,11 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||
else in the app ever used that authority. -->
|
||||
|
||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||
implements Configuration.Provider and supplies the
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
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.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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 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.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
|
||||
* not just a way to satisfy the analyzer: the row now carries two gestures, a
|
||||
* swipe background, and its own accessibility surface, which is more behaviour
|
||||
* than the screen that lists it. `internal` rather than `private` only because
|
||||
* QueueList (still in QueueScreen.kt) is the caller.
|
||||
*/
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
internal 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 {
|
||||
Color.Transparent
|
||||
}
|
||||
// Swipe left to remove, replacing the X button (#2395 follow-up). Only
|
||||
// end-to-start is enabled: a right-swipe has no meaning here, and leaving it
|
||||
// live would delete tracks on a mis-aimed gesture in either direction.
|
||||
val dismissState = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { value ->
|
||||
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||
onRemove()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = false,
|
||||
backgroundContent = { RemoveSwipeBackground() },
|
||||
// The reorder lift lives out here so a row being dragged vertically
|
||||
// carries its swipe container with it rather than sliding out of one.
|
||||
modifier = Modifier
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY },
|
||||
) {
|
||||
QueueRowContent(
|
||||
track = track,
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
isCurrent = isCurrent,
|
||||
liked = liked,
|
||||
highlight = highlight,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onClick = onClick,
|
||||
onToggleLike = onToggleLike,
|
||||
onRemove = onRemove,
|
||||
onMove = onMove,
|
||||
onDragOffset = { dragOffsetY = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueRowContent(
|
||||
track: TrackRef,
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
isCurrent: Boolean,
|
||||
liked: Boolean,
|
||||
highlight: Color,
|
||||
rowHeightPx: Int,
|
||||
onClick: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragOffset: (Float) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Opaque: this sits ON TOP of the red remove background, so a
|
||||
// transparent row would show the fill through it at rest.
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.queueReorderActions(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
onMove = onMove,
|
||||
onRemove = onRemove,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// The album art IS the grab surface (#2395). The grip icon it replaces
|
||||
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
|
||||
// narrowest surface in the app, competing with the title for space.
|
||||
QueueRowThumbnail(
|
||||
track = track,
|
||||
dragModifier = Modifier.queueReorderDrag(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = onDragOffset,
|
||||
onMove = onMove,
|
||||
),
|
||||
)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the row slides off to reveal: the destructive colour with a trash glyph,
|
||||
* pinned to the trailing edge because that is the edge the swipe uncovers.
|
||||
*
|
||||
* Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design
|
||||
* system keeps those apart deliberately — an error is a failure that already
|
||||
* happened, a destructive action is one about to happen — and using the error
|
||||
* colour here would dress an intentional gesture as a fault report.
|
||||
*/
|
||||
@Composable
|
||||
private fun RemoveSwipeBackground() {
|
||||
val actions = LocalActionColors.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(actions.destructive)
|
||||
.padding(horizontal = 24.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction)
|
||||
Text(
|
||||
text = "Remove",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = actions.onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-reader reordering and removal for a queue row.
|
||||
*
|
||||
* Both gestures this row now relies on — long-press-drag to reorder, swipe to
|
||||
* remove — are touch-only and unavailable under TalkBack, and each replaced a
|
||||
* control that a screen reader COULD find (the grip's "Reorder track", the X's
|
||||
* "Remove from queue"). Without these actions the row would have lost both
|
||||
* capabilities for anyone not using touch. They're the Android counterpart to
|
||||
* the web row's ArrowUp/ArrowDown keys and its still-present X button.
|
||||
*/
|
||||
private fun Modifier.queueReorderActions(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
): Modifier = semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction("Move up") {
|
||||
if (index > 0) { onMove(index, index - 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Move down") {
|
||||
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||
},
|
||||
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder-drag behaviour for a queue row, applied to whatever element is the
|
||||
* grab surface — the album art, since #2395 removed the grip icon.
|
||||
*
|
||||
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
|
||||
* is the load-bearing detail. The grip was a small target, so a plain drag
|
||||
* gesture on it never competed with anything. A 48dp thumbnail is a large
|
||||
* chunk of every row, and with a plain drag detector any vertical pan starting
|
||||
* on artwork would be swallowed as a row-reorder instead of scrolling the
|
||||
* queue — the list would feel broken precisely where it's easiest to touch.
|
||||
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
|
||||
* tap still plays (the detector doesn't consume a plain tap, so it falls
|
||||
* through to the row's clickable).
|
||||
*/
|
||||
private fun Modifier.queueReorderDrag(
|
||||
index: Int,
|
||||
queueSize: Int,
|
||||
rowHeightPx: Int,
|
||||
onOffsetChange: (Float) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
): Modifier = composed {
|
||||
// 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) }
|
||||
pointerInput(index, queueSize, rowHeightPx) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
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, dragModifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.then(dragModifier),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||
@@ -1,23 +1,16 @@
|
||||
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
|
||||
@@ -31,39 +24,20 @@ 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)
|
||||
@@ -203,157 +177,6 @@ private fun JumpToCurrentPill(
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
Color.Transparent
|
||||
}
|
||||
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,
|
||||
contentDescription = "Now playing",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
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 = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" · ")
|
||||
|
||||
/** "N tracks · 12 min" header summary. */
|
||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
@@ -367,6 +190,5 @@ private fun queueSummary(tracks: List<TrackRef>): String {
|
||||
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
|
||||
|
||||
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One of three terminal states the Check-for-updates button surfaces.
|
||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
||||
* system installer via [ApkInstaller].
|
||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||
* installs it via [ApkInstaller].
|
||||
*/
|
||||
sealed interface UpdateCheckResult {
|
||||
data object Idle : UpdateCheckResult
|
||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
val isChecking: Boolean = false,
|
||||
val isInstalling: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||
)
|
||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
||||
* [UpdateRepository.getLatest], compares versus the build's
|
||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and hands it to the system installer — routing the
|
||||
* user to the "install unknown apps" settings page first when that
|
||||
* permission hasn't been granted.
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
* granted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AboutCardViewModel @Inject constructor(
|
||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (internal.value.isInstalling) return
|
||||
if (internal.value.installStage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
internal.update {
|
||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
internal.update { it.copy(isInstalling = false) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
val why = ErrorCopy.fromThrowable(e)
|
||||
internal.update {
|
||||
it.copy(
|
||||
isInstalling = false,
|
||||
installMessage = "Couldn't download update: $why",
|
||||
)
|
||||
}
|
||||
internal.update {
|
||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// The install half now suspends on the platform's verdict, so it
|
||||
// gets its own stage — reporting "Downloading…" through it would
|
||||
// be a lie once a confirm dialog is on screen.
|
||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||
val outcome = installer.install(apk)
|
||||
internal.update {
|
||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
internal.update {
|
||||
it.copy(
|
||||
installStage = InstallStage.ERROR,
|
||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
enabled = !state.isChecking && !state.isInstalling,
|
||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isChecking) {
|
||||
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (available != null) {
|
||||
InstallButton(
|
||||
version = available.info.version,
|
||||
isInstalling = state.isInstalling,
|
||||
stage = state.installStage,
|
||||
onClick = { viewModel.install(available.info) },
|
||||
)
|
||||
}
|
||||
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = !isInstalling,
|
||||
enabled = !stage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isInstalling) {
|
||||
if (stage.isBusy()) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(if (isInstalling) "Downloading…" else "Install $version")
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install $version"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val APK_FILENAME = "minstrel-update.apk"
|
||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
||||
|
||||
/**
|
||||
* Downloads the server-bundled APK and hands it to Android's package
|
||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
||||
* side that the Flutter MethodChannel delegated to.
|
||||
* Downloads the server-bundled APK and installs it over ourselves.
|
||||
*
|
||||
* The download goes through the shared [OkHttpClient] so it inherits
|
||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||
* cache dir, exposed to the system installer via the app's
|
||||
* FileProvider content:// URI.
|
||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||
* [android.content.pm.PackageInstaller] session.
|
||||
*
|
||||
* On Android O+ the user must have granted "install unknown apps"
|
||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
||||
* opens the relevant settings screen.
|
||||
* opens the relevant settings screen. That grant is still required with
|
||||
* the session API — silent *updates* don't imply silent *permission*.
|
||||
*/
|
||||
@Singleton
|
||||
class ApkInstaller @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val session: SelfUpdateSession,
|
||||
) {
|
||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
||||
fun launchInstall(apk: File) {
|
||||
val uri: Uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apk,
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, APK_MIME)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
/**
|
||||
* Install [apk] over ourselves, suspending until the platform decides.
|
||||
*
|
||||
* Note for callers: on a successful silent install this never returns —
|
||||
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||
*/
|
||||
suspend fun install(apk: File): InstallOutcome = session.run(apk)
|
||||
|
||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||
fun requestInstallPermission() {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
/**
|
||||
* Terminal verdict from the platform on a self-update install (#2438).
|
||||
*
|
||||
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
|
||||
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
|
||||
* reports back, so "declined" and "failed" stop looking identical.
|
||||
*/
|
||||
sealed interface InstallOutcome {
|
||||
/**
|
||||
* The platform completed the install.
|
||||
*
|
||||
* Rarely observed on a self-update: our process is replaced the moment the
|
||||
* new APK lands, so the coroutine awaiting this usually dies before it
|
||||
* resumes. Modelled anyway — silently relying on being killed would make
|
||||
* the success path invisible to anyone reading this.
|
||||
*/
|
||||
data object Installed : InstallOutcome
|
||||
|
||||
/** The user declined the platform's confirm dialog. Not an error. */
|
||||
data object Cancelled : InstallOutcome
|
||||
|
||||
/** The platform refused. [reason] is its own message, where it gave one. */
|
||||
data class Failed(val reason: String?) : InstallOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||
* Settings About card.
|
||||
*
|
||||
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
|
||||
* state because the install half was fire-and-forget and took no time from our
|
||||
* side; now that we await the platform's verdict, collapsing them would leave
|
||||
* the UI claiming "Downloading…" through an install that can sit on a confirm
|
||||
* dialog indefinitely.
|
||||
*/
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||
|
||||
/** True while an install is underway and a second tap should do nothing. */
|
||||
fun InstallStage.isBusy(): Boolean =
|
||||
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||
|
||||
/**
|
||||
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||
* be a lie with a red tint.
|
||||
*/
|
||||
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||
*
|
||||
* Lives beside the outcome rather than in either UI package because two
|
||||
* separate screens surface the same verdicts and must not drift — the same
|
||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||
* outside the UI layer.
|
||||
*/
|
||||
fun InstallOutcome.message(): String? = when (this) {
|
||||
InstallOutcome.Installed -> null
|
||||
InstallOutcome.Cancelled -> "Update cancelled."
|
||||
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
|
||||
?: "Couldn't install update."
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val STAGED_APK_NAME = "minstrel-update"
|
||||
|
||||
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
|
||||
private const val WRITE_FROM_START = 0L
|
||||
|
||||
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
|
||||
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
|
||||
|
||||
/**
|
||||
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
|
||||
*
|
||||
* Split from [ApkInstaller] because the two halves are different work — one
|
||||
* speaks HTTP, the other speaks to the package manager — and the session half
|
||||
* carries a receiver, a PendingIntent and version-gated params that would
|
||||
* crowd the downloader out of its own file.
|
||||
*
|
||||
* ## Why a session, rather than the ACTION_VIEW intent this replaced
|
||||
*
|
||||
* Two reasons, and the second is the one that matters to users.
|
||||
*
|
||||
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
|
||||
* URI and hoped. It could not report an outcome, so a failed install and a
|
||||
* user who declined looked identical — see [InstallOutcome].
|
||||
*
|
||||
* More importantly, a session is where the platform lets a self-updater say it
|
||||
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
|
||||
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
|
||||
* manifest permission, is the sanctioned way to update with **no dialog at
|
||||
* all**. The platform grants that when all of: the installer opts in (here),
|
||||
* the installed app targets API 29+ (we're on 36), the installer holds the
|
||||
* permission (we do), and the target is the installer itself or something it
|
||||
* first installed (we are updating ourselves). All four hold.
|
||||
*
|
||||
* ## What is deliberately absent
|
||||
*
|
||||
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
|
||||
* a self-updater and it is not: ownership can only be claimed on **initial**
|
||||
* installation — setting it on an update is documented as a no-op — and it also
|
||||
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
|
||||
* stores claiming the apps they install, not for an app updating itself.
|
||||
*/
|
||||
@Singleton
|
||||
class SelfUpdateSession @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
/**
|
||||
* Stage [apk] and hand it to the platform, suspending until a terminal
|
||||
* verdict arrives.
|
||||
*
|
||||
* Never returns on the happy path when the install is silent: the platform
|
||||
* replaces this process the moment the new APK lands, so the coroutine dies
|
||||
* rather than resuming. Callers must treat that as success, not a hang.
|
||||
*/
|
||||
suspend fun run(apk: File): InstallOutcome {
|
||||
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
|
||||
return staged.fold(
|
||||
onSuccess = { sessionId -> awaitCommit(sessionId) },
|
||||
onFailure = { InstallOutcome.Failed(it.message) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Open a session, stream the APK in, return the session id. */
|
||||
private fun stage(apk: File): Int {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val sessionId = installer.createSession(newParams())
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
|
||||
apk.inputStream().use { source -> source.copyTo(sink) }
|
||||
// fsync before the session closes: the platform validates the
|
||||
// staged bytes at commit, and buffered tail bytes read as a
|
||||
// truncated APK.
|
||||
session.fsync(sink)
|
||||
}
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
|
||||
// runs on assembleRelease, and NewApi is easier for it to reason about when
|
||||
// the guarded call has a named receiver instead of an implicit one.
|
||||
private fun newParams(): PackageInstaller.SessionParams {
|
||||
val params = PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||
)
|
||||
params.setAppPackageName(context.packageName)
|
||||
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The whole point of this class. Pre-S there is no such API, so the
|
||||
// confirm dialog is unavoidable there — degrade, don't fail.
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the session and wait for the platform to report back.
|
||||
*
|
||||
* A pending-user-action status is *not* terminal — the platform is asking us
|
||||
* to show its dialog, and the real verdict arrives in a second broadcast
|
||||
* once the user decides. So the receiver stays registered across it.
|
||||
*/
|
||||
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(unused: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(
|
||||
PackageInstaller.EXTRA_STATUS,
|
||||
PackageInstaller.STATUS_FAILURE,
|
||||
)
|
||||
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||
confirmWithUser(intent)
|
||||
} else {
|
||||
context.unregisterReceiver(this)
|
||||
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
|
||||
}
|
||||
}
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(RESULT_ACTION),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
continuation.invokeOnCancellation {
|
||||
// Stop listening, but deliberately do NOT abandon the session.
|
||||
// Cancellation here means our caller's scope died — the user
|
||||
// navigated away, or the VM cleared — and by this point the
|
||||
// session is already committed. The user asked for this install;
|
||||
// killing it because nobody is watching the banner any more
|
||||
// would be the wrong reading of their intent.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
runCatching {
|
||||
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
|
||||
}.onFailure { error ->
|
||||
// Resuming normally means invokeOnCancellation never fires, so
|
||||
// clean up the staged session here or it sits until it expires.
|
||||
runCatching { context.unregisterReceiver(receiver) }
|
||||
runCatching { installer.abandonSession(sessionId) }
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(InstallOutcome.Failed(error.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultSender(sessionId: Int): IntentSender {
|
||||
// Scoped to our own package so the broadcast can't be answered elsewhere.
|
||||
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// The platform writes its status extras into this intent, so it has
|
||||
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
|
||||
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
}
|
||||
// Session id as the request code keeps concurrent sessions from
|
||||
// colliding on FLAG_UPDATE_CURRENT.
|
||||
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the platform's own confirm dialog, which arrives as an extra.
|
||||
*
|
||||
* The system-app check is not ceremony. Below API 34 a dynamically
|
||||
* registered receiver cannot declare itself unexported, so another app on
|
||||
* the device can broadcast [RESULT_ACTION] at us — and calling
|
||||
* `startActivity` on an attacker-supplied extra would hand it whatever we
|
||||
* can reach. The genuine confirm activity belongs to the platform
|
||||
* installer, so demanding a system component costs the real path nothing.
|
||||
*/
|
||||
private fun confirmWithUser(result: Intent) {
|
||||
val pending = IntentCompat.getParcelableExtra(
|
||||
result,
|
||||
Intent.EXTRA_INTENT,
|
||||
Intent::class.java,
|
||||
) ?: return
|
||||
if (isPlatformActivity(pending)) {
|
||||
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPlatformActivity(intent: Intent): Boolean {
|
||||
val flags = intent.resolveActivityInfo(context.packageManager, 0)
|
||||
?.applicationInfo
|
||||
?.flags
|
||||
?: 0
|
||||
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
return (flags and systemFlags) != 0
|
||||
}
|
||||
|
||||
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||
else -> InstallOutcome.Failed(message)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
/**
|
||||
* Shell-level soft banner that nudges an available update. Renders
|
||||
@@ -79,7 +81,7 @@ private fun BannerBody(
|
||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||
if (stage == InstallStage.DOWNLOADING) {
|
||||
if (stage.isBusy()) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||
// Downloading and installing are separate words because they're now
|
||||
// separate waits — the install half suspends on the platform, which
|
||||
// may be sitting on a confirm dialog.
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
|
||||
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Install lifecycle for the banner's Install button. */
|
||||
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
|
||||
|
||||
data class UpdateBannerUiState(
|
||||
val info: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
||||
|
||||
/**
|
||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||
* and runs the download → system-install handoff via [ApkInstaller],
|
||||
* mirroring the About card's flow (route to "install unknown apps"
|
||||
* settings first when the permission is missing).
|
||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||
* the About card's flow (route to "install unknown apps" settings first
|
||||
* when the permission is missing).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateBannerViewModel @Inject constructor(
|
||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val installState = MutableStateFlow(IdleInstall)
|
||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||
|
||||
val uiState: StateFlow<UpdateBannerUiState> =
|
||||
combine(controller.available, installState) { info, install ->
|
||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
fun dismiss(version: String) = controller.dismiss(version)
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
||||
if (installState.value.stage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
installState.value = InstallSnapshot(
|
||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||
runCatching { installer.downloadApk(info.apkUrl) }
|
||||
.onSuccess { apk ->
|
||||
installer.launchInstall(apk)
|
||||
installState.value = IdleInstall
|
||||
}
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// Await the platform's verdict rather than firing an intent and
|
||||
// assuming it worked. On a silent install this suspends until
|
||||
// the process is replaced, so the line below is only reached
|
||||
// when the install did NOT simply succeed.
|
||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||
val outcome = installer.install(apk)
|
||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||
|
||||
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Adaptive icon (API 26+). Before this the app shipped legacy bitmaps only,
|
||||
so modern launchers letterboxed the square instead of masking it to the
|
||||
device's icon shape. The foreground PNGs are drawn on a 108dp canvas with
|
||||
the mark inside the 66dp safe zone, so no mask can clip it. -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Obsidian. The adaptive icon's plate; chosen over the raised-surface
|
||||
iron because the accent note only clears the 3:1 graphics contrast
|
||||
threshold against this darker value (3.04:1 vs 2.70:1). -->
|
||||
<color name="ic_launcher_background">#14171A</color>
|
||||
</resources>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- The downloaded update APK lives in the app cache dir; the
|
||||
FileProvider exposes just that directory to the system
|
||||
installer via a content:// URI. -->
|
||||
<cache-path
|
||||
name="updates"
|
||||
path="." />
|
||||
</paths>
|
||||
@@ -2,7 +2,19 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<!--
|
||||
SVG first: it flips the M with the viewer's colour scheme, which the PNG
|
||||
can't. The PNG is the fallback for browsers without SVG-favicon support
|
||||
and is plated for the same reason apple-touch-icon is — see brand/.
|
||||
Ordering matters: browsers take the last icon they understand, so the
|
||||
PNG must come FIRST or it wins over the SVG in Chrome.
|
||||
-->
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" sizes="32x32" />
|
||||
<link rel="icon" href="%sveltekit.assets%/brand/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<!-- Obsidian: matches --fs-surface-page so mobile browser chrome doesn't
|
||||
seam against the app's own background. -->
|
||||
<meta name="theme-color" content="#14171A" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minstrel</title>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
// The Minstrel mark: a Didone M whose right leg is an eighth note.
|
||||
//
|
||||
// Inlined rather than <img src="mark.svg"> on purpose — an <img> cannot
|
||||
// inherit currentColor, and inheriting it is the whole point: the letter
|
||||
// takes the surrounding text colour, so it reads on both the dark and light
|
||||
// palettes without a second asset. Parchment-on-parchment is invisible,
|
||||
// which is exactly the bug a fixed fill would reintroduce.
|
||||
//
|
||||
// The note keeps the accent in both modes — one of the places the design
|
||||
// system sanctions the accent (the wordmark).
|
||||
//
|
||||
// ⚠ These paths are duplicated in web/static/brand/favicon.svg and
|
||||
// web/static/brand/mark.svg, which need literal colours instead of
|
||||
// currentColor (a favicon has no cascade to inherit from). Change the
|
||||
// silhouette here, change it there.
|
||||
//
|
||||
// aria-hidden: every current use sits directly beside the words "Minstrel",
|
||||
// so labelling it would make a screen reader announce the name twice. A
|
||||
// STANDALONE use would need its own label.
|
||||
let { size = 20, class: klass = '' }: { size?: number; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="202 251 902 723"
|
||||
width={size * 902 / 723}
|
||||
height={size}
|
||||
class={klass}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
@@ -57,22 +57,41 @@
|
||||
class="flex items-center gap-2 border-b border-border px-3 py-2 h-16
|
||||
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reorder track (use arrow keys)"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
onkeydown={handleHandleKeydown}
|
||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
<!--
|
||||
The album art is the grab surface (#2395). The grip used to occupy its own
|
||||
column in every row; it now sits OVER the art, so it costs no horizontal
|
||||
space at all. `use:draggable` is on the row (above), so dragging already
|
||||
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||
the keyboard target, and both survive here.
|
||||
|
||||
<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"
|
||||
/>
|
||||
It stays VISIBLE at rest, just quiet — it is the only thing that says this
|
||||
list can be reordered at all, so hiding it until hover would trade the
|
||||
operator's space complaint for a discoverability one (rule #24), and would
|
||||
leave nothing for touch, which has no hover. The scrim only appears on
|
||||
hover/focus so the artwork stays legible the rest of the time; the drop
|
||||
shadow is what keeps the glyph readable over pale covers without one.
|
||||
-->
|
||||
<div class="relative h-10 w-10 flex-shrink-0">
|
||||
<img
|
||||
src={coverUrl(track.album_id)}
|
||||
alt=""
|
||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||
class="h-10 w-10 rounded object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Reorder track (use arrow keys)"
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
onkeydown={handleHandleKeydown}
|
||||
class="group absolute inset-0 flex cursor-grab items-center justify-center rounded
|
||||
text-white/70 transition hover:bg-black/45 hover:text-white
|
||||
focus-visible:bg-black/45 focus-visible:text-white
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style="filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.9))"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -94,4 +94,30 @@ describe('QueueTrackRow', () => {
|
||||
await fireEvent.keyDown(handle, { key: ' ' });
|
||||
expect(moveQueueItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- handle placement (#2395) ---
|
||||
|
||||
it('the reorder handle overlays the album art instead of taking its own column', () => {
|
||||
const { container } = render(QueueTrackRow, {
|
||||
props: { track: sampleTrack, index: 3, isCurrent: false }
|
||||
});
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
const art = container.querySelector('img');
|
||||
expect(art).not.toBeNull();
|
||||
// Sharing a parent is what "overlaid" means structurally. If someone moves
|
||||
// the grip back into its own flex slot, this fails — which is the point:
|
||||
// that slot cost horizontal space in every row and is why #2395 exists.
|
||||
expect(handle.parentElement).toBe(art!.parentElement);
|
||||
});
|
||||
|
||||
it('the handle is visible at rest, not hover-revealed', () => {
|
||||
render(QueueTrackRow, { props: { track: sampleTrack, index: 3, isCurrent: false } });
|
||||
const handle = screen.getByLabelText(/reorder track/i);
|
||||
// Overlaying already solved the space complaint, so there is nothing to buy
|
||||
// by hiding it — and hiding it would cost the only cue that the queue can
|
||||
// be reordered, on touch especially, where there is no hover at all.
|
||||
// Asserting the absence of `opacity-0` is stylistic and a bit brittle, but
|
||||
// it is the only handle jsdom gives us on a decision worth protecting.
|
||||
expect(handle.className).not.toMatch(/\bopacity-0\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { user, logout } from '$lib/auth/store.svelte';
|
||||
import { player } from '$lib/player/store.svelte';
|
||||
import { appName } from '$lib/branding';
|
||||
import MinstrelMark from './MinstrelMark.svelte';
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import SearchInput from './SearchInput.svelte';
|
||||
|
||||
@@ -66,7 +67,11 @@
|
||||
whenever search or the user menu grew, so the nav drifted off
|
||||
window-center. Grid pins each column to a fixed lane. -->
|
||||
<header class="grid grid-cols-3 items-center border-b border-border bg-surface px-3 md:px-4 py-2 gap-3 md:gap-6">
|
||||
<a href="/" class="font-semibold text-sm md:text-base whitespace-nowrap justify-self-start">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-2 font-semibold text-sm md:text-base whitespace-nowrap justify-self-start"
|
||||
>
|
||||
<MinstrelMark size={20} class="shrink-0" />
|
||||
{appName()}
|
||||
</a>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="157 116 992 992">
|
||||
<!-- Minstrel mark. The M flips with the viewer's scheme because a favicon
|
||||
sits on browser chrome we don't control: parchment would vanish on a
|
||||
light tab strip, obsidian on a dark one. The note keeps the accent in
|
||||
both — teal holds against either. -->
|
||||
<style>
|
||||
.m { fill: #E8E4D8; }
|
||||
@media (prefers-color-scheme: light) { .m { fill: #14171A; } }
|
||||
</style>
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path class="m" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="202 251 902 723">
|
||||
<g transform="translate(0,1254) scale(0.1,-0.1)" fill-rule="evenodd">
|
||||
<path fill="currentColor" d="M2252 9878 l3 -152 109 -22 c342 -72 492 -184 538 -405 16 -74 24
|
||||
-4823 9 -5024 -20 -266 -73 -375 -236 -484 -116 -77 -332 -150 -543 -181 -114
|
||||
-18 -107 -6 -110 -165 -1 -76 2 -143 7 -148 9 -9 2607 -11 2623 -1 14 9 10
|
||||
280 -4 291 -7 5 -49 15 -93 22 -380 60 -638 193 -720 374 -63 136 -59 -12 -61
|
||||
2247 -3 1999 -2 2054 15 2015 116 -253 646 -1520 1183 -2825 71 -173 216 -524
|
||||
322 -780 206 -494 266 -640 370 -895 97 -237 68 -210 226 -210 l135 0 23 50
|
||||
c13 27 95 212 182 410 134 307 747 1685 1015 2285 92 205 726 1599 910 2000
|
||||
65 140 126 274 137 297 22 50 52 71 74 52 12 -10 14 -266 14 -1864 l0 -1852
|
||||
-82 -7 c-347 -29 -716 -203 -973 -460 -710 -712 -343 -1645 650 -1649 453 -2
|
||||
892 184 1206 510 193 202 295 397 351 676 l23 112 0 2357 c0 1297 0 2358 1
|
||||
2358 12 0 142 -49 179 -67 342 -173 607 -545 715 -1004 85 -361 66 -801 -51
|
||||
-1215 -43 -151 -40 -173 18 -174 53 0 284 377 396 650 243 590 303 1238 163
|
||||
1754 -178 652 -643 1100 -1294 1248 -93 21 -122 22 -716 25 -707 4 -649 12
|
||||
-696 -90 -15 -34 -78 -172 -140 -307 -593 -1297 -1123 -2469 -1717 -3800 -210
|
||||
-472 -193 -437 -204 -418 -11 19 -173 423 -630 1568 -214 536 -394 986 -400
|
||||
1000 -6 14 -76 187 -156 385 -80 198 -182 452 -228 565 -46 113 -140 346 -209
|
||||
518 -205 507 -225 554 -244 568 -14 11 -209 13 -1055 14 l-1038 0 3 -152z"/>
|
||||
<path fill="#4A6B5C" d="M8830 7450 l0 -2582 -32 6 c-517 106 -1064 -47 -1442 -405 -598 -566
|
||||
-501 -1354 196 -1598 489 -170 1134 -19 1548 363 234 217 350 418 423 736 l22
|
||||
95 3 2373 c2 1961 5 2372 16 2372 27 0 132 -41 205 -81 315 -169 569 -524 675
|
||||
-944 50 -198 60 -285 60 -525 0 -288 -27 -487 -105 -756 -34 -120 -35 -130
|
||||
-11 -143 33 -18 64 11 154 144 227 334 402 795 469 1235 37 238 34 646 -4 843
|
||||
-149 761 -637 1271 -1353 1418 -98 20 -147 23 -466 27 l-358 4 0 -2582z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 1.3 KiB |