Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
e1e591b520 |
@@ -11,10 +11,22 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||||
|
|
||||||
|
## Scope and responsible use
|
||||||
|
|
||||||
|
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||||
|
|
||||||
|
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||||
|
|
||||||
|
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||||
|
|
||||||
|
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||||
|
|
||||||
|
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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.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.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
@@ -48,15 +57,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
android:name="androidx.core.content.FileProvider"
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
android:authorities="${applicationId}.fileprovider"
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
android:exported="false"
|
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||||
android:grantUriPermissions="true">
|
else in the app ever used that authority. -->
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||||
implements Configuration.Provider and supplies the
|
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
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -31,39 +24,20 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.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.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.ArrowDown
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.GripVertical
|
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Music
|
|
||||||
import com.composables.icons.lucide.Trash2
|
import com.composables.icons.lucide.Trash2
|
||||||
import com.composables.icons.lucide.Volume2
|
|
||||||
import com.composables.icons.lucide.X
|
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@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. */
|
/** "N tracks · 12 min" header summary. */
|
||||||
private fun queueSummary(tracks: List<TrackRef>): String {
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
@@ -367,6 +190,5 @@ private fun queueSummary(tracks: List<TrackRef>): String {
|
|||||||
return "${tracks.size} $noun · $length"
|
return "${tracks.size} $noun · $length"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
|
||||||
private const val SECONDS_PER_MINUTE = 60
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
private const val MINUTES_PER_HOUR = 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.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
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.UpdateRepository
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of three terminal states the Check-for-updates button surfaces.
|
* One of three terminal states the Check-for-updates button surfaces.
|
||||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||||
* system installer via [ApkInstaller].
|
* installs it via [ApkInstaller].
|
||||||
*/
|
*/
|
||||||
sealed interface UpdateCheckResult {
|
sealed interface UpdateCheckResult {
|
||||||
data object Idle : UpdateCheckResult
|
data object Idle : UpdateCheckResult
|
||||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
|||||||
data class AboutUiState(
|
data class AboutUiState(
|
||||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val isInstalling: Boolean = false,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||||
)
|
)
|
||||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
|||||||
* [UpdateRepository.getLatest], compares versus the build's
|
* [UpdateRepository.getLatest], compares versus the build's
|
||||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||||
* When an update is available, [install] downloads the APK via
|
* When an update is available, [install] downloads the APK via
|
||||||
* [ApkInstaller] and hands it to the system installer — routing the
|
* [ApkInstaller] and installs it — routing the user to the "install
|
||||||
* user to the "install unknown apps" settings page first when that
|
* unknown apps" settings page first when that permission hasn't been
|
||||||
* permission hasn't been granted.
|
* granted.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AboutCardViewModel @Inject constructor(
|
class AboutCardViewModel @Inject constructor(
|
||||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (internal.value.isInstalling) return
|
if (internal.value.installStage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
internal.update {
|
internal.update {
|
||||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
internal.update {
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||||
.onSuccess { apk ->
|
}
|
||||||
installer.launchInstall(apk)
|
val apk = download(info.apkUrl)
|
||||||
internal.update { it.copy(isInstalling = false) }
|
if (apk != null) {
|
||||||
}
|
// The install half now suspends on the platform's verdict, so it
|
||||||
.onFailure { e ->
|
// gets its own stage — reporting "Downloading…" through it would
|
||||||
val why = ErrorCopy.fromThrowable(e)
|
// be a lie once a confirm dialog is on screen.
|
||||||
internal.update {
|
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||||
it.copy(
|
val outcome = installer.install(apk)
|
||||||
isInstalling = false,
|
internal.update {
|
||||||
installMessage = "Couldn't download update: $why",
|
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.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.theme.ThemeMode
|
import com.fabledsword.minstrel.theme.ThemeMode
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
enabled = !state.isChecking && !state.isInstalling,
|
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (state.isChecking) {
|
if (state.isChecking) {
|
||||||
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
if (available != null) {
|
if (available != null) {
|
||||||
InstallButton(
|
InstallButton(
|
||||||
version = available.info.version,
|
version = available.info.version,
|
||||||
isInstalling = state.isInstalling,
|
stage = state.installStage,
|
||||||
onClick = { viewModel.install(available.info) },
|
onClick = { viewModel.install(available.info) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = !isInstalling,
|
enabled = !stage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (isInstalling) {
|
if (stage.isBusy()) {
|
||||||
ButtonSpinner()
|
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.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val APK_FILENAME = "minstrel-update.apk"
|
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
|
* Downloads the server-bundled APK and installs it over ourselves.
|
||||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
|
||||||
* side that the Flutter MethodChannel delegated to.
|
|
||||||
*
|
*
|
||||||
* The download goes through the shared [OkHttpClient] so it inherits
|
* The download goes through the shared [OkHttpClient] so it inherits
|
||||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||||
* cache dir, exposed to the system installer via the app's
|
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||||
* FileProvider content:// URI.
|
* [android.content.pm.PackageInstaller] session.
|
||||||
*
|
*
|
||||||
* On Android O+ the user must have granted "install unknown apps"
|
* On Android O+ the user must have granted "install unknown apps"
|
||||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
* 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
|
@Singleton
|
||||||
class ApkInstaller @Inject constructor(
|
class ApkInstaller @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val session: SelfUpdateSession,
|
||||||
) {
|
) {
|
||||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
|||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
context.packageManager.canRequestPackageInstalls()
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
/**
|
||||||
fun launchInstall(apk: File) {
|
* Install [apk] over ourselves, suspending until the platform decides.
|
||||||
val uri: Uri = FileProvider.getUriForFile(
|
*
|
||||||
context,
|
* Note for callers: on a successful silent install this never returns —
|
||||||
"${context.packageName}.fileprovider",
|
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||||
apk,
|
*/
|
||||||
)
|
suspend fun install(apk: File): InstallOutcome = session.run(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||||
fun requestInstallPermission() {
|
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.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
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
|
* 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),
|
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||||
if (stage == InstallStage.DOWNLOADING) {
|
if (stage.isBusy()) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
// 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) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
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(
|
data class UpdateBannerUiState(
|
||||||
val info: UpdateInfo? = null,
|
val info: UpdateInfo? = null,
|
||||||
val stage: InstallStage = InstallStage.IDLE,
|
val stage: InstallStage = InstallStage.IDLE,
|
||||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||||
* and runs the download → system-install handoff via [ApkInstaller],
|
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||||
* mirroring the About card's flow (route to "install unknown apps"
|
* the About card's flow (route to "install unknown apps" settings first
|
||||||
* settings first when the permission is missing).
|
* when the permission is missing).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class UpdateBannerViewModel @Inject constructor(
|
class UpdateBannerViewModel @Inject constructor(
|
||||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
private val installer: ApkInstaller,
|
private val installer: ApkInstaller,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val installState = MutableStateFlow(IdleInstall)
|
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||||
|
|
||||||
val uiState: StateFlow<UpdateBannerUiState> =
|
val uiState: StateFlow<UpdateBannerUiState> =
|
||||||
combine(controller.available, installState) { info, install ->
|
combine(controller.available, installState) { info, install ->
|
||||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
fun dismiss(version: String) = controller.dismiss(version)
|
fun dismiss(version: String) = controller.dismiss(version)
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
if (installState.value.stage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(
|
||||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
val apk = download(info.apkUrl)
|
||||||
.onSuccess { apk ->
|
if (apk != null) {
|
||||||
installer.launchInstall(apk)
|
// Await the platform's verdict rather than firing an intent and
|
||||||
installState.value = IdleInstall
|
// assuming it worked. On a silent install this suspends until
|
||||||
}
|
// the process is replaced, so the line below is only reached
|
||||||
.onFailure { e ->
|
// when the install did NOT simply succeed.
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||||
InstallStage.ERROR,
|
val outcome = installer.install(apk)
|
||||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
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 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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Minstrel</title>
|
<title>Minstrel</title>
|
||||||
<script>
|
<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
|
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' : ''}"
|
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
|
||||||
>
|
>
|
||||||
<button
|
<!--
|
||||||
type="button"
|
The album art is the grab surface (#2395). The grip used to occupy its own
|
||||||
aria-label="Reorder track (use arrow keys)"
|
column in every row; it now sits OVER the art, so it costs no horizontal
|
||||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
space at all. `use:draggable` is on the row (above), so dragging already
|
||||||
onkeydown={handleHandleKeydown}
|
worked from anywhere — the grip's real jobs are being the visual cue and
|
||||||
class="cursor-grab text-text-secondary hover:text-text-primary flex-shrink-0"
|
the keyboard target, and both survive here.
|
||||||
>
|
|
||||||
<GripVertical size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<img
|
It stays VISIBLE at rest, just quiet — it is the only thing that says this
|
||||||
src={coverUrl(track.album_id)}
|
list can be reordered at all, so hiding it until hover would trade the
|
||||||
alt=""
|
operator's space complaint for a discoverability one (rule #24), and would
|
||||||
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
leave nothing for touch, which has no hover. The scrim only appears on
|
||||||
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -94,4 +94,30 @@ describe('QueueTrackRow', () => {
|
|||||||
await fireEvent.keyDown(handle, { key: ' ' });
|
await fireEvent.keyDown(handle, { key: ' ' });
|
||||||
expect(moveQueueItem).not.toHaveBeenCalled();
|
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 { user, logout } from '$lib/auth/store.svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { player } from '$lib/player/store.svelte';
|
||||||
import { appName } from '$lib/branding';
|
import { appName } from '$lib/branding';
|
||||||
|
import MinstrelMark from './MinstrelMark.svelte';
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import SearchInput from './SearchInput.svelte';
|
import SearchInput from './SearchInput.svelte';
|
||||||
|
|
||||||
@@ -66,7 +67,11 @@
|
|||||||
whenever search or the user menu grew, so the nav drifted off
|
whenever search or the user menu grew, so the nav drifted off
|
||||||
window-center. Grid pins each column to a fixed lane. -->
|
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">
|
<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()}
|
{appName()}
|
||||||
</a>
|
</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 |