Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727f68950e | ||
|
|
955a61194e | ||
|
|
b96285d6d9 | ||
|
|
7ba673ed83 | ||
|
|
366692a1fc | ||
|
|
6d729d1512 | ||
|
|
414dfb23b6 | ||
|
|
952132714e | ||
|
|
c2862e97bd | ||
|
|
30a5ac56ce | ||
|
|
bab9b16831 | ||
|
|
03a8d12079 | ||
|
|
0036f534db | ||
|
|
bfb6c9acfe | ||
|
|
3eada70aac | ||
|
|
d9238ec5be | ||
|
|
a31b672b14 | ||
|
|
8d1f2674fd | ||
|
|
845f45fb0b | ||
|
|
aab90a7a39 | ||
|
|
4c49ee2cc6 | ||
|
|
4dd0a58d63 | ||
|
|
c3f3a17c6d | ||
|
|
20bd7bfaf8 | ||
|
|
aa9f534f3c | ||
|
|
8e1d25a772 | ||
|
|
4509f740f8 | ||
|
|
011b4d9a9c | ||
|
|
a254cb2273 | ||
|
|
e368b82f0a | ||
|
|
d5aa081157 | ||
|
|
304de88c50 | ||
|
|
96abb48086 | ||
|
|
a094d5f8b0 | ||
|
|
481f906059 | ||
|
|
a99f855e98 | ||
|
|
24d330424f | ||
|
|
f6d1cf24f0 | ||
|
|
7e4727fc49 | ||
|
|
fd27819cdd | ||
|
|
37b396a7e4 | ||
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
57d2299180 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
fa7ea41ccf | ||
|
|
e1e591b520 |
@@ -9,7 +9,6 @@ web/build
|
|||||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||||
# `COPY . .` layer cache on every Flutter-only change.
|
# `COPY . .` layer cache on every Flutter-only change.
|
||||||
flutter_client/
|
|
||||||
|
|
||||||
# Docs and IDE noise
|
# Docs and IDE noise
|
||||||
docs/
|
docs/
|
||||||
|
|||||||
@@ -98,6 +98,31 @@ jobs:
|
|||||||
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
|
||||||
|
|
||||||
|
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
|
||||||
|
# Release" below resolves the release by tag and fails if it is absent —
|
||||||
|
# but that is the final step, so a tag pushed without a release built an
|
||||||
|
# APK for several minutes first and only then discovered it had nowhere to
|
||||||
|
# put it. Same check, seconds in instead of minutes.
|
||||||
|
#
|
||||||
|
# Releases are normally created through the API (which creates the tag and
|
||||||
|
# the release together, so this passes). A bare `git push origin vX` is the
|
||||||
|
# case this catches.
|
||||||
|
- name: Release must exist for this tag
|
||||||
|
shell: bash
|
||||||
|
working-directory: ${{ github.workspace }}
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
if ! curl -fsSL -o /dev/null \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"; then
|
||||||
|
echo "::error::no release exists for ${TAG}. Create the release (which creates the tag) rather than pushing a bare tag — otherwise there is nothing to attach the APK to."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::release found for ${TAG}"
|
||||||
|
|
||||||
- name: Cache Gradle dirs
|
- name: Cache Gradle dirs
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -322,3 +347,79 @@ jobs:
|
|||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||||
--push ${{ steps.tags.outputs.args }} .
|
--push ${{ steps.tags.outputs.args }} .
|
||||||
|
|
||||||
|
# Verifies a tag release actually ended up complete, and names the specific
|
||||||
|
# thing that's missing if not.
|
||||||
|
#
|
||||||
|
# Added 2026-08-07 after v2026.08.07 was re-cut. The android-release job never
|
||||||
|
# started — no log was written at all — so all eight of its steps reported
|
||||||
|
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||||
|
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||||
|
# already moved `:latest`, so the code was deployable and nothing looked
|
||||||
|
# obviously wrong. The release was simply missing its APK and its immutable
|
||||||
|
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||||
|
#
|
||||||
|
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||||
|
# anything in this file). What it does is turn an incomplete release into an
|
||||||
|
# explicit, named error instead of eight mystery step failures — so the
|
||||||
|
# consequence is legible without having to infer it.
|
||||||
|
#
|
||||||
|
# `if: always()` is the whole point: it has to report precisely when the jobs
|
||||||
|
# above did NOT succeed.
|
||||||
|
verify-release:
|
||||||
|
name: Verify release artifacts (tag releases only)
|
||||||
|
needs: [android-release, image-release]
|
||||||
|
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
runs-on: go-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Release must have an APK attached
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
|
||||||
|
REL_JSON="$(curl -fsSL \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" || true)"
|
||||||
|
if [ -z "${REL_JSON}" ]; then
|
||||||
|
echo "::error::no release found for ${TAG} — the tag exists but nothing was published"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK="$(printf '%s' "${REL_JSON}" \
|
||||||
|
| grep -oP '"browser_download_url":\s*"\K[^"]+' \
|
||||||
|
| grep -E '\.apk$' | head -1 || true)"
|
||||||
|
if [ -z "${APK}" ]; then
|
||||||
|
echo "::error::release ${TAG} has NO APK attached — in-app update will offer nothing, and the bundled-APK path on future :latest builds has no source."
|
||||||
|
echo "::error::Fix by RE-RUNNING this workflow run. Do NOT delete and re-create the tag; if it fails again the runner never started the container, and the evidence is in act_runner on the host (Gitea will hold no job log)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::notice::APK attached: ${APK}"
|
||||||
|
|
||||||
|
# The other half. Checking only the APK would report success on a release
|
||||||
|
# whose image push failed — which is precisely the second thing that was
|
||||||
|
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||||
|
# it runs even when image-release failed, so without this the guard would
|
||||||
|
# cheerfully verify an incomplete release.
|
||||||
|
- name: Immutable image tag must exist
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||||
|
|
||||||
|
echo "${{ secrets.CI_TOKEN }}" \
|
||||||
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||||
|
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||||
|
|||||||
-14
@@ -52,20 +52,6 @@ GEMINI.md
|
|||||||
.windsurfrules
|
.windsurfrules
|
||||||
.aider.conf.yml
|
.aider.conf.yml
|
||||||
|
|
||||||
# Flutter
|
|
||||||
flutter_client/.dart_tool/
|
|
||||||
flutter_client/.flutter-plugins
|
|
||||||
flutter_client/.flutter-plugins-dependencies
|
|
||||||
flutter_client/build/
|
|
||||||
flutter_client/.idea/
|
|
||||||
flutter_client/ios/Podfile.lock
|
|
||||||
flutter_client/ios/Pods/
|
|
||||||
flutter_client/android/.gradle/
|
|
||||||
flutter_client/android/app/build/
|
|
||||||
flutter_client/android/local.properties
|
|
||||||
flutter_client/android/key.properties
|
|
||||||
flutter_client/*.iml
|
|
||||||
|
|
||||||
# Native Android (Kotlin/Compose) — M8 rewrite
|
# Native Android (Kotlin/Compose) — M8 rewrite
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/.kotlin/
|
android/.kotlin/
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
@@ -19,9 +28,9 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Minstrel"
|
android:theme="@style/Theme.Minstrel"
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
tools:targetApi="34">
|
tools:targetApi="34">
|
||||||
|
|
||||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||||
@@ -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
|
||||||
|
|||||||
-2
@@ -11,8 +11,6 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-through accessor for the admin cross-user requests queue.
|
* Read-through accessor for the admin cross-user requests queue.
|
||||||
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
|
|
||||||
* AdminRequestsController.
|
|
||||||
*
|
*
|
||||||
* No Room caching — admin actions are infrequent and don't benefit
|
* No Room caching — admin actions are infrequent and don't benefit
|
||||||
* from offline scrollback. `approve` and `reject` fire direct REST
|
* from offline scrollback. `approve` and `reject` fire direct REST
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.nav.AdminQuarantine
|
|||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
@@ -112,6 +113,7 @@ fun AdminLandingScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -42,6 +43,7 @@ fun AdminQuarantineScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -41,6 +42,7 @@ fun AdminRequestsScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -49,6 +50,7 @@ fun AdminTagSourcesScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -127,6 +128,7 @@ private fun AdminUsersScaffold(
|
|||||||
onRevokeInvite: (String) -> Unit,
|
onRevokeInvite: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import java.io.IOException
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps server error codes (and common transport failures) to
|
* Maps server error codes (and common transport failures) to
|
||||||
* friendly, sentence-case copy. Mirrors
|
* friendly, sentence-case copy.
|
||||||
* `flutter_client/assets/error-copy.json` + `error_copy.dart`.
|
|
||||||
*
|
*
|
||||||
* Server errors are `{"error":{"code":"...","message":"..."}}`.
|
* Server errors are `{"error":{"code":"...","message":"..."}}`.
|
||||||
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
|
* [fromThrowable] pulls the code out of a Retrofit [HttpException]'s
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/invites`. Mirrors
|
* Retrofit interface for `/api/admin/invites`.
|
||||||
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
|
|
||||||
*
|
*
|
||||||
* Server TTL is hardcoded at 24h; the only configurable field is the
|
* Server TTL is hardcoded at 24h; the only configurable field is the
|
||||||
* optional `note` on create.
|
* optional `note` on create.
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/quarantine`. Mirrors
|
* Retrofit interface for `/api/admin/quarantine`.
|
||||||
* `flutter_client/lib/api/endpoints/admin_quarantine.dart`.
|
|
||||||
*
|
*
|
||||||
* Three resolution endpoints:
|
* Three resolution endpoints:
|
||||||
* - `resolve` → admin reviewed, no action taken (clears flags).
|
* - `resolve` → admin reviewed, no action taken (clears flags).
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/requests`. Mirrors
|
* Retrofit interface for `/api/admin/requests`.
|
||||||
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
|
|
||||||
*
|
*
|
||||||
* Server returns the same `requestView` shape as the user-side
|
* Server returns the same `requestView` shape as the user-side
|
||||||
* `/api/requests`, so RequestWire is reused. Different listing scope —
|
* `/api/requests`, so RequestWire is reused. Different listing scope —
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import retrofit2.http.PUT
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/admin/users`. Mirrors
|
* Retrofit interface for `/api/admin/users`.
|
||||||
* `flutter_client/lib/api/endpoints/admin_users.dart`.
|
|
||||||
*
|
*
|
||||||
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
|
* Note: the PUT-auto-approve body field is `auto_approve`, NOT
|
||||||
* `auto_approve_requests` — the request shape differs from the
|
* `auto_approve_requests` — the request shape differs from the
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import retrofit2.http.Body
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/auth`. Mirrors
|
* Retrofit interface for `/api/auth`.
|
||||||
* `flutter_client/lib/api/endpoints/auth.dart`.
|
|
||||||
*
|
*
|
||||||
* The actual session-cookie capture happens in
|
* The actual session-cookie capture happens in
|
||||||
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
|
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import retrofit2.http.Query
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for Discover / Lidarr search / request creation.
|
* Retrofit interface for Discover / Lidarr search / request creation.
|
||||||
* Mirrors `flutter_client/lib/api/endpoints/discover.dart`.
|
|
||||||
*
|
*
|
||||||
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
|
||||||
* of the same query are cheap.
|
* of the same query are cheap.
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import retrofit2.http.Body
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `POST /api/events`. Mirrors the relevant
|
* Retrofit interface for `POST /api/events`. All four
|
||||||
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
|
|
||||||
* variants share the same URL — the discriminator is in the request
|
* variants share the same URL — the discriminator is in the request
|
||||||
* body's `type` field. Server contract is best-effort per spec;
|
* body's `type` field. Server contract is best-effort per spec;
|
||||||
* callers (the live path in PlayEventsReporter) swallow errors and
|
* callers (the live path in PlayEventsReporter) swallow errors and
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/me/history`. Mirrors the relevant
|
* Retrofit interface for `/api/me/history` — history only. The profile,
|
||||||
* subset of `flutter_client/lib/api/endpoints/me.dart` (only
|
* timezone and quarantine endpoints on `/api/me` live with their own
|
||||||
* `history()`; profile / timezone / quarantine endpoints land with
|
* features rather than here.
|
||||||
* their respective phases).
|
|
||||||
*/
|
*/
|
||||||
interface HistoryApi {
|
interface HistoryApi {
|
||||||
@GET("api/me/history")
|
@GET("api/me/history")
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import com.fabledsword.minstrel.models.wire.HomeIndexWire
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for the Home discovery endpoint. Mirrors
|
* Retrofit interface for the Home discovery endpoint. Only the ID-only
|
||||||
* `flutter_client/lib/api/endpoints/home.dart` — just the ID-only
|
* `/api/home/index` variant is used. The server also serves a heavier
|
||||||
* `/api/home/index` variant. The Flutter port has a heavier
|
* `/api/home` (full embedded payload); we don't use it because
|
||||||
* `/api/home` (full embedded payload) too; we don't use it because
|
|
||||||
* the per-item hydration path (sync controller → Room → Flow) is
|
* the per-item hydration path (sync controller → Room → Flow) is
|
||||||
* the only one the native client needs.
|
* the only one the native client needs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.GenreCountWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.PagedAlbumsWire
|
||||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.YearCountWire
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for the server's native `/api/...` library surface.
|
* Retrofit interface for the server's native `/api/...` library surface.
|
||||||
* Mirrors `flutter_client/lib/api/endpoints/library.dart` 1:1.
|
|
||||||
*
|
*
|
||||||
* Notes on shapes:
|
* Notes on shapes:
|
||||||
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
|
* - `GET /api/artists/{id}` returns ArtistDetailWire (ArtistRef fields
|
||||||
@@ -54,6 +56,49 @@ interface LibraryApi {
|
|||||||
@GET("api/library/shuffle")
|
@GET("api/library/shuffle")
|
||||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||||
|
|
||||||
|
// Browse axes (#367). Both indexes are unpaged by design: the client needs
|
||||||
|
// the whole set to render a browsable picker, and even a messy library
|
||||||
|
// yields hundreds of rows, not thousands.
|
||||||
|
//
|
||||||
|
// These read the server rather than the local cache on purpose. The cache
|
||||||
|
// is a full mirror of the library, but /api/library/sync ships tracks whose
|
||||||
|
// files are missing and carries no flag for it (#2704), while the browse
|
||||||
|
// index filters them out -- so a locally-computed index would disagree with
|
||||||
|
// the server's and with the web client. One source of truth wins over
|
||||||
|
// offline capability here until #2704 is resolved.
|
||||||
|
@GET("api/library/genres")
|
||||||
|
suspend fun getGenres(): List<GenreCountWire>
|
||||||
|
|
||||||
|
@GET("api/library/years")
|
||||||
|
suspend fun getAlbumYears(): List<YearCountWire>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Albums carrying [genre] on any of their tracks.
|
||||||
|
*
|
||||||
|
* @Query, never @Path: "Rock/Pop" is a real ID3 tag and a slash cannot
|
||||||
|
* survive a path segment. Retrofit percent-encodes query values correctly;
|
||||||
|
* a @Path would either 404 or silently address a different genre.
|
||||||
|
*/
|
||||||
|
@GET("api/library/albums")
|
||||||
|
suspend fun getAlbumsByGenre(
|
||||||
|
@Query("genre") genre: String,
|
||||||
|
@Query("limit") limit: Int,
|
||||||
|
@Query("offset") offset: Int,
|
||||||
|
): PagedAlbumsWire
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Albums released in an inclusive year range. Pass the same year twice for
|
||||||
|
* a single year. Sending a genre alongside these is a deliberate 400 on the
|
||||||
|
* server (`unsupported_filter_combination`) -- they are separate axes.
|
||||||
|
*/
|
||||||
|
@GET("api/library/albums")
|
||||||
|
suspend fun getAlbumsByYear(
|
||||||
|
@Query("year_from") yearFrom: Int,
|
||||||
|
@Query("year_to") yearTo: Int,
|
||||||
|
@Query("limit") limit: Int,
|
||||||
|
@Query("offset") offset: Int,
|
||||||
|
): PagedAlbumsWire
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||||
const val TOP_TRACKS_LIMIT = 5
|
const val TOP_TRACKS_LIMIT = 5
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/likes`. Mirrors
|
* Retrofit interface for `/api/likes`.
|
||||||
* `flutter_client/lib/api/endpoints/likes.dart`.
|
|
||||||
*
|
*
|
||||||
* Path segment `kind` is one of "artists" | "albums" | "tracks"
|
* Path segment `kind` is one of "artists" | "albums" | "tracks"
|
||||||
* (plural, matching the server route). The Repository hides that
|
* (plural, matching the server route). The Repository hides that
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import retrofit2.http.PUT
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
|
* Retrofit interface for the `/api/me` endpoints — caller-scoped account endpoints.
|
||||||
* Mirrors the relevant slice of `flutter_client/lib/api/endpoints/settings.dart`.
|
|
||||||
*
|
*
|
||||||
* History + timezone + system-playlists-status live under /api/me too
|
* History + timezone + system-playlists-status live under /api/me too
|
||||||
* but are handled by their respective feature repositories; this
|
* but are handled by their respective feature repositories; this
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import retrofit2.http.Path
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/playlists`. Mirrors
|
* Retrofit interface for `/api/playlists`.
|
||||||
* `flutter_client/lib/api/endpoints/playlists.dart`.
|
|
||||||
*/
|
*/
|
||||||
interface PlaylistsApi {
|
interface PlaylistsApi {
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +53,7 @@ interface PlaylistsApi {
|
|||||||
* the system playlist's tracks in rotation-aware order without
|
* the system playlist's tracks in rotation-aware order without
|
||||||
* rebuilding — used by the Home play-button overlay so taps on For
|
* rebuilding — used by the Home play-button overlay so taps on For
|
||||||
* You / Discover / Today's mix advance rotation rather than picking
|
* You / Discover / Today's mix advance rotation rather than picking
|
||||||
* the stored order. Mirrors `playlists.dart.systemShuffle`.
|
* the stored order.
|
||||||
*/
|
*/
|
||||||
@GET("api/playlists/system/{kind}/shuffle")
|
@GET("api/playlists/system/{kind}/shuffle")
|
||||||
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
|
suspend fun systemShuffle(@Path("kind") variant: String): PlaylistDetailWire
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import retrofit2.http.POST
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
|
* Retrofit interface for `/api/quarantine`: flag and unflag, plus the
|
||||||
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
|
* `/api/quarantine/mine` listing.
|
||||||
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
|
|
||||||
*
|
*
|
||||||
* Both flag and unflag are user-scoped — callers act on their own
|
* Both flag and unflag are user-scoped — callers act on their own
|
||||||
* quarantine entries. The cross-user admin surface is a separate
|
* quarantine entries. The cross-user admin surface is a separate
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
|
* Retrofit interface for `/api/radio`. The server picks a fresh shuffle each
|
||||||
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
|
|
||||||
* returns the seeded queue). The server picks a fresh shuffle each
|
|
||||||
* invocation — clients call this once per radio start.
|
* invocation — clients call this once per radio start.
|
||||||
*/
|
*/
|
||||||
interface RadioApi {
|
interface RadioApi {
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for the user-side `/api/requests`. Mirrors
|
* Retrofit interface for the user-side `/api/requests`.
|
||||||
* `flutter_client/lib/api/endpoints/requests.dart`.
|
|
||||||
*
|
*
|
||||||
* Server scopes results to the caller — admins see only their own
|
* Server scopes results to the caller — admins see only their own
|
||||||
* requests through this endpoint. The cross-user admin view lives on
|
* requests through this endpoint. The cross-user admin view lives on
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `GET /api/search`. Mirrors
|
* Retrofit interface for `GET /api/search`. Server returns 400
|
||||||
* `flutter_client/lib/api/endpoints/search.dart`. Server returns 400
|
|
||||||
* on empty/whitespace-only `q` — the caller is responsible for
|
* on empty/whitespace-only `q` — the caller is responsible for
|
||||||
* guarding.
|
* guarding.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton facade over the auth state machine. Mirrors Flutter's
|
* Singleton facade over the auth state machine.
|
||||||
* `AuthController` from `auth_provider.dart`.
|
|
||||||
*
|
*
|
||||||
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
|
||||||
* Set-Cookie on the login response; the user identity itself
|
* Set-Cookie on the login response; the user identity itself
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import javax.inject.Singleton
|
|||||||
private const val POOL_LIMIT = 100
|
private const val POOL_LIMIT = 100
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offline play sources over the local audio-cache index. Mirrors
|
* Offline play sources over the local audio-cache index.
|
||||||
* `flutter_client/lib/cache/shuffle_source.dart`.
|
|
||||||
*
|
*
|
||||||
* Both pools are UNIONs over the cache regardless of storage bucket
|
* Both pools are UNIONs over the cache regardless of storage bucket
|
||||||
* (liked AND recently-played both included). The two-bucket split is
|
* (liked AND recently-played both included). The two-bucket split is
|
||||||
@@ -58,6 +57,14 @@ class ShuffleSource @Inject constructor(
|
|||||||
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
||||||
if (orderedIds.isEmpty()) return emptyList()
|
if (orderedIds.isEmpty()) return emptyList()
|
||||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||||
return orderedIds.mapNotNull { byId[it]?.toDomain() }
|
return orderedIds.mapNotNull { id ->
|
||||||
|
// Clear the server's missing mark (#2704). Every id reaching here
|
||||||
|
// came through residentIdsByRecency, which already proved the
|
||||||
|
// AUDIO is in the local cache — so these play regardless of what
|
||||||
|
// the server has lost, and the queue filter in PlayerController
|
||||||
|
// would otherwise throw away tracks that work perfectly. Missing
|
||||||
|
// means "cannot stream", not "cannot play".
|
||||||
|
byId[id]?.toDomain()?.copy(unavailable = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.cache.audiocache
|
package com.fabledsword.minstrel.cache.audiocache
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defaults for the 2-bucket audio cache. Matches the Flutter client.
|
* Defaults for the 2-bucket audio cache.
|
||||||
*
|
*
|
||||||
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
* - `likedCapBytes`: cap for the protected bucket — cached files for
|
||||||
* tracks the user has liked. Evicted only after the rolling bucket
|
* tracks the user has liked. Evicted only after the rolling bucket
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@ private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
|
|||||||
private const val DEFAULT_PREFETCH_WINDOW = 5
|
private const val DEFAULT_PREFETCH_WINDOW = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
|
* User-tunable audio cache settings. Persisted as a JSON
|
||||||
* (cache_settings_provider.dart) field-for-field. Persisted as a JSON
|
|
||||||
* blob on the auth_session single-row table via [AuthStore].
|
* blob on the auth_session single-row table via [AuthStore].
|
||||||
*
|
*
|
||||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||||
|
|||||||
@@ -65,9 +65,13 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
],
|
],
|
||||||
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
version = 7,
|
// which is exactly right here: the next sync refills every row with the
|
||||||
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
|
version = 8,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
|
|||||||
-1
@@ -59,7 +59,6 @@ interface CachedPlaylistDao {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically reconciles the cache against the fresh list response.
|
* Atomically reconciles the cache against the fresh list response.
|
||||||
* Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` —
|
|
||||||
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
|
* `BuildSystemPlaylists` rotates system-playlist UUIDs every
|
||||||
* rebuild, so upsert alone leaves stale rows whose detail fetch
|
* rebuild, so upsert alone leaves stale rows whose detail fetch
|
||||||
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
|
* 404s. Delete any of the user's rows not in [freshOwnedIds] (this
|
||||||
|
|||||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per fully-downloaded audio file. Mirrors
|
* One row per fully-downloaded audio file. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `AudioCacheIndex` Drift table.
|
* the Flutter client's `AudioCacheIndex` Drift table.
|
||||||
*
|
*
|
||||||
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
|
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
|
||||||
* - `incidental` files (streamed-and-cached side effect) evict first
|
* - `incidental` files (streamed-and-cached side effect) evict first
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one album. Mirrors `flutter_client/lib/cache/db.dart`'s
|
* Cache row for one album. Mirrors the Flutter client's
|
||||||
* `CachedAlbums` Drift table.
|
* `CachedAlbums` Drift table.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "cached_albums")
|
@Entity(tableName = "cached_albums")
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one artist. Mirrors `flutter_client/lib/cache/db.dart`'s
|
* Cache row for one artist. Mirrors the Flutter client's
|
||||||
* `CachedArtists` Drift table.
|
* `CachedArtists` Drift table.
|
||||||
*
|
*
|
||||||
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
* Column names follow Kotlin idiom (camelCase) rather than Drift's
|
||||||
|
|||||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-item row driving the Home screen sections. Mirrors
|
* Per-item row driving the Home screen sections. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedHomeIndex` Drift table.
|
* the Flutter client's `CachedHomeIndex` Drift table.
|
||||||
*
|
*
|
||||||
* `section` is one of (matching /api/home keys):
|
* `section` is one of (matching /api/home keys):
|
||||||
* - "recently_added_albums"
|
* - "recently_added_albums"
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like membership row. Mirrors `flutter_client/lib/cache/db.dart`'s
|
* Like membership row. Mirrors the Flutter client's
|
||||||
* `CachedLikes` Drift table. Composite primary key — one user may
|
* `CachedLikes` Drift table. Composite primary key — one user may
|
||||||
* independently like a track AND its album AND its artist; rows are
|
* independently like a track AND its album AND its artist; rows are
|
||||||
* disambiguated by the (userId, entityType, entityId) triple.
|
* disambiguated by the (userId, entityType, entityId) triple.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per pending offline-write. Mirrors
|
* One row per pending offline-write. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedMutations` Drift table.
|
* the Flutter client's `CachedMutations` Drift table.
|
||||||
*
|
*
|
||||||
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
* MutationQueue.enqueue() inserts a row when a server-write fails with
|
||||||
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
* an IOException; MutationReplayer.drain() pops and re-attempts each
|
||||||
|
|||||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one playlist (user or system). Mirrors
|
* Cache row for one playlist (user or system). Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
|
* the Flutter client's `CachedPlaylists` Drift table.
|
||||||
*
|
*
|
||||||
* `systemVariant` is null for user playlists and one of
|
* `systemVariant` is null for user playlists and one of
|
||||||
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
|
||||||
|
|||||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ import androidx.room.Entity
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered membership of tracks within a playlist. Mirrors
|
* Ordered membership of tracks within a playlist. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
|
* the Flutter client's `CachedPlaylistTracks` Drift table.
|
||||||
* Composite PK so the same track can only appear once per playlist;
|
* Composite PK so the same track can only appear once per playlist;
|
||||||
* `position` carries the ordering.
|
* `position` carries the ordering.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The current user's quarantine flag for one track. Mirrors
|
* The current user's quarantine flag for one track. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedQuarantineMine` Drift
|
* the Flutter client's `CachedQuarantineMine` Drift
|
||||||
* table.
|
* table.
|
||||||
*
|
*
|
||||||
* The flat denormalized track/album/artist columns let the Quarantine
|
* The flat denormalized track/album/artist columns let the Quarantine
|
||||||
|
|||||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import kotlinx.datetime.Instant
|
|||||||
/**
|
/**
|
||||||
* Single-row snapshot of the last playback session — queue (as JSON),
|
* Single-row snapshot of the last playback session — queue (as JSON),
|
||||||
* current index, position, and source tag. Mirrors
|
* current index, position, and source tag. Mirrors
|
||||||
* `flutter_client/lib/cache/db.dart`'s `CachedResumeState` Drift table.
|
* the Flutter client's `CachedResumeState` Drift table.
|
||||||
*
|
*
|
||||||
* Lets a torn-down session (the player's idle/dismissed teardown)
|
* Lets a torn-down session (the player's idle/dismissed teardown)
|
||||||
* resume on next launch; without it the headset / lock-screen play
|
* resume on next launch; without it the headset / lock-screen play
|
||||||
|
|||||||
+6
-1
@@ -6,8 +6,12 @@ import kotlinx.datetime.Clock
|
|||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache row for one track. Mirrors `flutter_client/lib/cache/db.dart`'s
|
* Cache row for one track. Mirrors the Flutter client's
|
||||||
* `CachedTracks` Drift table.
|
* `CachedTracks` Drift table.
|
||||||
|
*
|
||||||
|
* [missing] carries the server's missing-file mark (#2704). Every read that
|
||||||
|
* can put a track in front of the user — or in a queue — must exclude it, and
|
||||||
|
* the DAO queries do that rather than each call site remembering to.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "cached_tracks")
|
@Entity(tableName = "cached_tracks")
|
||||||
data class CachedTrackEntity(
|
data class CachedTrackEntity(
|
||||||
@@ -21,5 +25,6 @@ data class CachedTrackEntity(
|
|||||||
val filePath: String? = null,
|
val filePath: String? = null,
|
||||||
val fileFormat: String? = null,
|
val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
|
val missing: Boolean = false,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -219,4 +219,5 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
|||||||
filePath = filePath,
|
filePath = filePath,
|
||||||
fileFormat = fileFormat,
|
fileFormat = fileFormat,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
|
missing = missing,
|
||||||
)
|
)
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
|||||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||||
* source, and the playback-error reporter.
|
* source, and the playback-error reporter.
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
* - recheck() from pull-to-refresh and the banner.
|
||||||
|
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||||
|
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||||
|
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||||
|
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||||
|
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||||
|
* to play music that would have played fine.
|
||||||
*
|
*
|
||||||
* Version compatibility is a byproduct of the same /healthz response.
|
* Version compatibility is a byproduct of the same /healthz response.
|
||||||
*
|
*
|
||||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
connectivity: ConnectivityObserver,
|
connectivity: ConnectivityObserver,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) : DefaultLifecycleObserver {
|
||||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||||
private val machine = ReachabilityMachine()
|
private val machine = ReachabilityMachine()
|
||||||
private val lastProbeAtMs = AtomicLong(0)
|
private val lastProbeAtMs = AtomicLong(0)
|
||||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
scope.launch { reduceLoop() }
|
scope.launch { reduceLoop() }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
connectivity.online.collect { up ->
|
connectivity.online.collect { up ->
|
||||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
|||||||
scope.launch { probeOnce(force = true) }
|
scope.launch { probeOnce(force = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App returned to the foreground — probe now rather than waiting for the
|
||||||
|
* poll loop (#1209).
|
||||||
|
*
|
||||||
|
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||||
|
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||||
|
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
|
||||||
|
* a user bringing the app up is exactly when a stale banner and a refused
|
||||||
|
* track are most visible, and it's a once-per-foreground cost.
|
||||||
|
*/
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
recheck()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun reduceLoop() {
|
private suspend fun reduceLoop() {
|
||||||
for (intent in intents) {
|
for (intent in intents) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
+27
-1
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
|||||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||||
|
* (#1209).
|
||||||
|
*
|
||||||
|
* A link handoff fails every in-flight request at once, so a burst is one
|
||||||
|
* event producing N failures — not N independent observations that the server
|
||||||
|
* is gone. Without this, two simultaneous failures corroborated each other
|
||||||
|
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||||
|
* every uncached track. The app declined to play music that would have played
|
||||||
|
* fine, for a blip that had already resolved.
|
||||||
|
*
|
||||||
|
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||||
|
* still letting a genuine outage corroborate within seconds once a client
|
||||||
|
* retries. The sustained-time backstop covers the case where nothing retries
|
||||||
|
* at all — and if nothing is asking, a late ServerDown costs nothing.
|
||||||
|
*/
|
||||||
|
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
|||||||
recentOpFailures.clear()
|
recentOpFailures.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
/**
|
||||||
|
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||||
|
*
|
||||||
|
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||||
|
* recorded one are dropped rather than stacked: see that constant for why
|
||||||
|
* a burst must not corroborate itself.
|
||||||
|
*/
|
||||||
fun onOpFailure(nowMs: Long) {
|
fun onOpFailure(nowMs: Long) {
|
||||||
pruneOpFailures(nowMs)
|
pruneOpFailures(nowMs)
|
||||||
|
val last = recentOpFailures.lastOrNull()
|
||||||
|
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||||
recentOpFailures.addLast(nowMs)
|
recentOpFailures.addLast(nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import com.fabledsword.minstrel.models.LidarrRequestKind
|
|||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
@@ -61,6 +62,7 @@ fun DiscoverScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ private const val BACKOFF_FACTOR = 2
|
|||||||
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
|
* ViewModels + the central [LiveEventsDispatcher]) collect filtered
|
||||||
* subsets of the stream.
|
* subsets of the stream.
|
||||||
*
|
*
|
||||||
* Connection lifecycle mirrors
|
* Connection lifecycle:
|
||||||
* `flutter_client/lib/shared/live_events_provider.dart`:
|
|
||||||
* - Gated on having a session cookie. Subscription opens when the
|
* - Gated on having a session cookie. Subscription opens when the
|
||||||
* cookie transitions to non-null and closes when it transitions
|
* cookie transitions to non-null and closes when it transitions
|
||||||
* back to null (sign-out).
|
* back to null (sign-out).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parsed event from the server's SSE stream. Mirrors
|
* Parsed event from the server's SSE stream. Mirrors
|
||||||
* `flutter_client/lib/shared/live_events_provider.dart`'s `LiveEvent`.
|
* the Flutter client's `LiveEvent`.
|
||||||
*
|
*
|
||||||
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
|
||||||
* - [userId] is the actor whose user-scoped state changed (empty for
|
* - [userId] is the actor whose user-scoped state changed (empty for
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
|
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Activated
|
||||||
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
|
|
||||||
* by force-@Inject in MinstrelApplication.
|
* by force-@Inject in MinstrelApplication.
|
||||||
*
|
*
|
||||||
* Scope is deliberately narrow: this dispatcher only touches state
|
* Scope is deliberately narrow: this dispatcher only touches state
|
||||||
|
|||||||
@@ -204,8 +204,7 @@ private const val HOURS_PER_DAY = 24L
|
|||||||
private const val DAYS_PER_WEEK = 7L
|
private const val DAYS_PER_WEEK = 7L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight relative-time formatter mirroring Flutter's
|
* Lightweight relative-time formatter:
|
||||||
* `library_screen.dart`'s `_relativeTime`:
|
|
||||||
*
|
*
|
||||||
* < 1h → "Nm ago"
|
* < 1h → "Nm ago"
|
||||||
* < 24h → "Nh ago"
|
* < 24h → "Nh ago"
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import com.fabledsword.minstrel.shared.VeilOutcome
|
|||||||
import com.fabledsword.minstrel.shared.VeilSessionResult
|
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||||
import com.fabledsword.minstrel.shared.VeilSettleState
|
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
@@ -564,6 +565,7 @@ fun HomeScreen(
|
|||||||
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
|
viewModel.transientMessages.collect { snackbarHostState.showSnackbar(it) }
|
||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
MinstrelTopAppBar(
|
MinstrelTopAppBar(
|
||||||
@@ -1170,7 +1172,7 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||||
* (they need the server to generate, so they're meaningless offline).
|
* (they need the server to generate, so they're meaningless offline).
|
||||||
*
|
*
|
||||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
* Diverges from Flutter (the Flutter client
|
||||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||||
* surfaces the secondary kinds on Home. Operator authorized the
|
* surfaces the secondary kinds on Home. Operator authorized the
|
||||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||||
@@ -1372,7 +1374,7 @@ private const val MOST_PLAYED_COVER_DP = 48
|
|||||||
|
|
||||||
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
|
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
|
||||||
// rounded up. Mirrors Flutter (`CompactTrackCard` in
|
// rounded up. Mirrors Flutter (`CompactTrackCard` in
|
||||||
// flutter_client/lib/library/widgets/compact_track_card.dart) which
|
// the Flutter client) which
|
||||||
// uses a horizontal-row card pattern - much denser than the square
|
// uses a horizontal-row card pattern - much denser than the square
|
||||||
// per-track tiles that web uses (operator request 2026-06-01: "in the
|
// per-track tiles that web uses (operator request 2026-06-01: "in the
|
||||||
// flutter iteration the tiles were different and smaller so more of
|
// flutter iteration the tiles were different and smaller so more of
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ fun CachedTrackEntity.toDomain(
|
|||||||
trackNumber = trackNumber,
|
trackNumber = trackNumber,
|
||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSec = durationMs.millisToSeconds(),
|
durationSec = durationMs.millisToSeconds(),
|
||||||
|
unavailable = missing,
|
||||||
// Deterministic from track id; matches the server's stream_url
|
// Deterministic from track id; matches the server's stream_url
|
||||||
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
||||||
// didn't carry streamUrl before, which left MetadataProvider-
|
// didn't carry streamUrl before, which left MetadataProvider-
|
||||||
@@ -121,6 +122,7 @@ fun TrackWire.toDomain(): TrackRef =
|
|||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSec = durationSec,
|
durationSec = durationSec,
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
|
unavailable = unavailable,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ArtistWire.toDomain(): ArtistRef =
|
fun ArtistWire.toDomain(): ArtistRef =
|
||||||
|
|||||||
@@ -161,7 +161,52 @@ class LibraryRepository @Inject constructor(
|
|||||||
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
|
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
|
||||||
api.shuffleLibrary(limit = limit).map { it.toDomain() }
|
api.shuffleLibrary(limit = limit).map { it.toDomain() }
|
||||||
|
|
||||||
|
// ---- Browse axes (#367 / #2467) ----
|
||||||
|
//
|
||||||
|
// Server-backed rather than cache-first, unlike everything above. The
|
||||||
|
// cache mirrors the whole library but includes tracks whose files are
|
||||||
|
// missing, with no flag to spot them (#2704), while the server's index
|
||||||
|
// excludes them -- so a locally-derived index would quietly disagree with
|
||||||
|
// the web client's. Revisit when #2704 lands.
|
||||||
|
|
||||||
|
/** Genre index, ordered by track count then name (server order). */
|
||||||
|
suspend fun genres(): List<GenreCount> =
|
||||||
|
api.getGenres().map { GenreCount(genre = it.genre, trackCount = it.trackCount) }
|
||||||
|
|
||||||
|
/** Year index, newest first. Albums with no release date are absent. */
|
||||||
|
suspend fun albumYears(): List<YearCount> =
|
||||||
|
api.getAlbumYears().map { YearCount(year = it.year, albumCount = it.albumCount) }
|
||||||
|
|
||||||
|
/** One page of albums carrying [genre] on any track. */
|
||||||
|
suspend fun albumsByGenre(genre: String, limit: Int, offset: Int): AlbumPage {
|
||||||
|
val page = api.getAlbumsByGenre(genre = genre, limit = limit, offset = offset)
|
||||||
|
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of albums released in [year]. */
|
||||||
|
suspend fun albumsByYear(year: Int, limit: Int, offset: Int): AlbumPage {
|
||||||
|
val page = api.getAlbumsByYear(
|
||||||
|
yearFrom = year,
|
||||||
|
yearTo = year,
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
)
|
||||||
|
return AlbumPage(items = page.items.map { it.toDomain() }, total = page.total)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SHUFFLE_DEFAULT_LIMIT = 100
|
const val SHUFFLE_DEFAULT_LIMIT = 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of the genre index. */
|
||||||
|
data class GenreCount(val genre: String, val trackCount: Int)
|
||||||
|
|
||||||
|
/** One row of the year index. */
|
||||||
|
data class YearCount(val year: Int, val albumCount: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of albums plus the server's total for the whole filter, which is
|
||||||
|
* what lets the UI say how many are left rather than just offering "more".
|
||||||
|
*/
|
||||||
|
data class AlbumPage(val items: List<AlbumRef>, val total: Int)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
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
|
||||||
@@ -52,6 +51,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
@@ -70,6 +70,7 @@ fun AlbumDetailScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -165,7 +166,6 @@ private fun AlbumBody(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 140.dp),
|
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
AlbumHeader(
|
AlbumHeader(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
@@ -79,6 +80,7 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.library.data.AlbumPage
|
||||||
|
import com.fabledsword.minstrel.library.data.GenreCount
|
||||||
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
|
import com.fabledsword.minstrel.library.data.YearCount
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How the genre index is ordered. */
|
||||||
|
enum class GenreSort {
|
||||||
|
/** Server order: track count descending, name breaking ties. */
|
||||||
|
COUNT,
|
||||||
|
|
||||||
|
/** Alphabetical, case-insensitive. */
|
||||||
|
NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Albums for whichever genre or year is currently drilled into.
|
||||||
|
*
|
||||||
|
* [total] is the server's count for the whole filter, not the loaded slice,
|
||||||
|
* so the UI can say how many are left instead of only offering "more".
|
||||||
|
*/
|
||||||
|
data class AlbumBrowseState(
|
||||||
|
val albums: List<AlbumRef> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val failed: Boolean = false,
|
||||||
|
) {
|
||||||
|
val hasMore: Boolean get() = albums.size < total
|
||||||
|
val remaining: Int get() = (total - albums.size).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
|
||||||
|
* shipped.
|
||||||
|
*
|
||||||
|
* Both indexes come from the server, which is a deliberate departure from the
|
||||||
|
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
|
||||||
|
* whose files are missing and cannot tell you which (#2704), while the server's
|
||||||
|
* index excludes them, so a locally-derived index would disagree with the web
|
||||||
|
* client's. These two tabs therefore need a connection; the empty states say so
|
||||||
|
* rather than looking broken.
|
||||||
|
*/
|
||||||
|
// Two browse axes, each with an index, a filter/sort or grouping, a
|
||||||
|
// drill-down and a pager. The function count is two axes' worth of a
|
||||||
|
// cohesive surface; splitting into GenresViewModel + YearsViewModel would
|
||||||
|
// duplicate the shared paging body for no gain.
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
|
@HiltViewModel
|
||||||
|
class BrowseViewModel @Inject constructor(
|
||||||
|
private val repository: LibraryRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val genresInternal = MutableStateFlow<UiState<List<GenreCount>>>(UiState.Loading)
|
||||||
|
val genres: StateFlow<UiState<List<GenreCount>>> = genresInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val yearsInternal = MutableStateFlow<UiState<List<YearCount>>>(UiState.Loading)
|
||||||
|
val years: StateFlow<UiState<List<YearCount>>> = yearsInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val genreFilterInternal = MutableStateFlow("")
|
||||||
|
val genreFilter: StateFlow<String> = genreFilterInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val genreSortInternal = MutableStateFlow(GenreSort.COUNT)
|
||||||
|
val genreSort: StateFlow<GenreSort> = genreSortInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val selectedGenreInternal = MutableStateFlow<String?>(null)
|
||||||
|
val selectedGenre: StateFlow<String?> = selectedGenreInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val selectedYearInternal = MutableStateFlow<Int?>(null)
|
||||||
|
val selectedYear: StateFlow<Int?> = selectedYearInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val genreAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||||
|
val genreAlbums: StateFlow<AlbumBrowseState> = genreAlbumsInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val yearAlbumsInternal = MutableStateFlow(AlbumBrowseState())
|
||||||
|
val yearAlbums: StateFlow<AlbumBrowseState> = yearAlbumsInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Guards against a slow response for a previously-selected genre/year
|
||||||
|
// landing after the user has moved on and painting over the new list.
|
||||||
|
// One counter per axis, since the two drill-downs are independent.
|
||||||
|
private var genreRequestToken = 0
|
||||||
|
private var yearRequestToken = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadGenres()
|
||||||
|
loadYears()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGenres() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
genresInternal.value = UiState.Loading
|
||||||
|
genresInternal.value = runCatching { repository.genres() }.fold(
|
||||||
|
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||||
|
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadYears() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
yearsInternal.value = UiState.Loading
|
||||||
|
yearsInternal.value = runCatching { repository.albumYears() }.fold(
|
||||||
|
onSuccess = { if (it.isEmpty()) UiState.Empty else UiState.Success(it) },
|
||||||
|
onFailure = { UiState.Error(ErrorCopy.fromThrowable(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGenreFilter(value: String) { genreFilterInternal.value = value }
|
||||||
|
|
||||||
|
fun setGenreSort(sort: GenreSort) { genreSortInternal.value = sort }
|
||||||
|
|
||||||
|
/** Drill into [genre], or pass null to go back to the index. */
|
||||||
|
fun selectGenre(genre: String?) {
|
||||||
|
selectedGenreInternal.value = genre
|
||||||
|
genreRequestToken += 1
|
||||||
|
genreAlbumsInternal.value = AlbumBrowseState()
|
||||||
|
if (genre == null) return
|
||||||
|
fetchGenrePage(genre, offset = 0, token = genreRequestToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMoreGenreAlbums() {
|
||||||
|
val genre = selectedGenreInternal.value ?: return
|
||||||
|
val state = genreAlbumsInternal.value
|
||||||
|
if (state.loading || !state.hasMore) return
|
||||||
|
fetchGenrePage(genre, offset = state.albums.size, token = genreRequestToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryGenreAlbums() {
|
||||||
|
selectedGenreInternal.value?.let { selectGenre(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drill into [year], or pass null to go back to the index. */
|
||||||
|
fun selectYear(year: Int?) {
|
||||||
|
selectedYearInternal.value = year
|
||||||
|
yearRequestToken += 1
|
||||||
|
yearAlbumsInternal.value = AlbumBrowseState()
|
||||||
|
if (year == null) return
|
||||||
|
fetchYearPage(year, offset = 0, token = yearRequestToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMoreYearAlbums() {
|
||||||
|
val year = selectedYearInternal.value ?: return
|
||||||
|
val state = yearAlbumsInternal.value
|
||||||
|
if (state.loading || !state.hasMore) return
|
||||||
|
fetchYearPage(year, offset = state.albums.size, token = yearRequestToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryYearAlbums() {
|
||||||
|
selectedYearInternal.value?.let { selectYear(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchGenrePage(genre: String, offset: Int, token: Int) {
|
||||||
|
fetchPage(
|
||||||
|
state = genreAlbumsInternal,
|
||||||
|
offset = offset,
|
||||||
|
isCurrent = { token == genreRequestToken },
|
||||||
|
fetch = { repository.albumsByGenre(genre, PAGE_SIZE, offset) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchYearPage(year: Int, offset: Int, token: Int) {
|
||||||
|
fetchPage(
|
||||||
|
state = yearAlbumsInternal,
|
||||||
|
offset = offset,
|
||||||
|
isCurrent = { token == yearRequestToken },
|
||||||
|
fetch = { repository.albumsByYear(year, PAGE_SIZE, offset) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paging body both axes share: append on success, and drop the result
|
||||||
|
* entirely if the selection moved while the request was in flight.
|
||||||
|
*/
|
||||||
|
private fun fetchPage(
|
||||||
|
state: MutableStateFlow<AlbumBrowseState>,
|
||||||
|
offset: Int,
|
||||||
|
isCurrent: () -> Boolean,
|
||||||
|
fetch: suspend () -> AlbumPage,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.value = state.value.copy(loading = true, failed = false)
|
||||||
|
runCatching { fetch() }.fold(
|
||||||
|
onSuccess = { page ->
|
||||||
|
if (!isCurrent()) return@launch
|
||||||
|
val merged =
|
||||||
|
if (offset == 0) page.items else state.value.albums + page.items
|
||||||
|
state.value = AlbumBrowseState(
|
||||||
|
albums = merged,
|
||||||
|
total = page.total,
|
||||||
|
loading = false,
|
||||||
|
failed = false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
if (!isCurrent()) return@launch
|
||||||
|
state.value = state.value.copy(loading = false, failed = true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Matches the web client's BROWSE_PAGE_SIZE so "Load more (N left)"
|
||||||
|
// steps at the same rate on both clients.
|
||||||
|
const val PAGE_SIZE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the current filter and sort to a genre index.
|
||||||
|
*
|
||||||
|
* Pure so the ordering rules are testable without a ViewModel. Sorting copies
|
||||||
|
* first: the input is the list held in the loaded state, and sorting in place
|
||||||
|
* would reorder what every other reader sees.
|
||||||
|
*/
|
||||||
|
fun visibleGenres(
|
||||||
|
genres: List<GenreCount>,
|
||||||
|
filter: String,
|
||||||
|
sort: GenreSort,
|
||||||
|
): List<GenreCount> {
|
||||||
|
val q = filter.trim()
|
||||||
|
val matched =
|
||||||
|
if (q.isEmpty()) genres else genres.filter { it.genre.contains(q, ignoreCase = true) }
|
||||||
|
return when (sort) {
|
||||||
|
// Server order is already count DESC then name; don't re-sort it.
|
||||||
|
GenreSort.COUNT -> matched
|
||||||
|
GenreSort.NAME -> matched.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.genre })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integer division by this floors a year to its decade: 2007 -> 2000. Named
|
||||||
|
// because detekt counts it as magic, and because the arithmetic reads as
|
||||||
|
// arbitrary otherwise.
|
||||||
|
private const val YEARS_PER_DECADE = 10
|
||||||
|
|
||||||
|
/** A decade's worth of the year index, newest year first. */
|
||||||
|
data class DecadeGroup(
|
||||||
|
val decade: Int,
|
||||||
|
val years: List<YearCount>,
|
||||||
|
val albumCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group the year index by decade, newest first.
|
||||||
|
*
|
||||||
|
* A flat list of every year in a decades-deep library is a wall of numbers, and
|
||||||
|
* the decade is usually how someone actually thinks about it. Pure, for the
|
||||||
|
* same reason as [visibleGenres].
|
||||||
|
*/
|
||||||
|
fun groupByDecade(years: List<YearCount>): List<DecadeGroup> =
|
||||||
|
years.groupBy { (it.year / YEARS_PER_DECADE) * YEARS_PER_DECADE }
|
||||||
|
.map { (decade, entries) ->
|
||||||
|
DecadeGroup(
|
||||||
|
decade = decade,
|
||||||
|
years = entries.sortedByDescending { it.year },
|
||||||
|
albumCount = entries.sumOf { it.albumCount },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.decade }
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.LibraryBig
|
||||||
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
|
||||||
|
*
|
||||||
|
* Two states in one tab rather than a navigation destination: the index, and
|
||||||
|
* the albums for a chosen genre. Back returns to the index. A route would have
|
||||||
|
* meant carrying the genre in the path, and "Rock/Pop" is a real ID3 tag whose
|
||||||
|
* slash a path segment cannot carry — the same reason the server takes it as a
|
||||||
|
* query parameter.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GenresTab(
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
viewModel: BrowseViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val selected by viewModel.selectedGenre.collectAsStateWithLifecycle()
|
||||||
|
val genre = selected
|
||||||
|
if (genre == null) {
|
||||||
|
GenreIndex(viewModel = viewModel)
|
||||||
|
} else {
|
||||||
|
GenreAlbums(
|
||||||
|
genre = genre,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreIndex(viewModel: BrowseViewModel) {
|
||||||
|
val state by viewModel.genres.collectAsStateWithLifecycle()
|
||||||
|
val filter by viewModel.genreFilter.collectAsStateWithLifecycle()
|
||||||
|
val sort by viewModel.genreSort.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
UiState.Loading -> EmptyState(
|
||||||
|
title = "Reading your genres…",
|
||||||
|
body = "",
|
||||||
|
icon = Lucide.LibraryBig,
|
||||||
|
)
|
||||||
|
UiState.Empty -> EmptyState(
|
||||||
|
title = "No genres found",
|
||||||
|
body = "Genres come from the genre tag on your audio files. If your " +
|
||||||
|
"library is tagged but this is empty, try a rescan from the admin " +
|
||||||
|
"screen.",
|
||||||
|
icon = Lucide.LibraryBig,
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
message = s.message,
|
||||||
|
onRetry = viewModel::loadGenres,
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val visible = visibleGenres(s.data, filter, sort)
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
GenreIndexControls(
|
||||||
|
total = s.data.size,
|
||||||
|
shown = visible.size,
|
||||||
|
filter = filter,
|
||||||
|
sort = sort,
|
||||||
|
onFilterChange = viewModel::setGenreFilter,
|
||||||
|
onSortChange = viewModel::setGenreSort,
|
||||||
|
)
|
||||||
|
if (visible.isEmpty()) {
|
||||||
|
EmptyState(
|
||||||
|
title = "No genres match \"${filter.trim()}\"",
|
||||||
|
body = "Try a shorter search.",
|
||||||
|
icon = Lucide.LibraryBig,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(items = visible, key = { it.genre }) { row ->
|
||||||
|
GenreRow(
|
||||||
|
genre = row.genre,
|
||||||
|
trackCount = row.trackCount,
|
||||||
|
onClick = { viewModel.selectGenre(row.genre) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreIndexControls(
|
||||||
|
total: Int,
|
||||||
|
shown: Int,
|
||||||
|
filter: String,
|
||||||
|
sort: GenreSort,
|
||||||
|
onFilterChange: (String) -> Unit,
|
||||||
|
onSortChange: (GenreSort) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = filter,
|
||||||
|
onValueChange = onFilterChange,
|
||||||
|
label = { Text("Filter genres") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Count-first is the default because the head of that list is
|
||||||
|
// genuinely where you are going; raw tags carry a long tail of
|
||||||
|
// one-offs that A-Z would bury the real genres under. A-Z is here
|
||||||
|
// for when you already know roughly what it is called.
|
||||||
|
FilterChip(
|
||||||
|
selected = sort == GenreSort.COUNT,
|
||||||
|
onClick = { onSortChange(GenreSort.COUNT) },
|
||||||
|
label = { Text("Most tracks") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = sort == GenreSort.NAME,
|
||||||
|
onClick = { onSortChange(GenreSort.NAME) },
|
||||||
|
label = { Text("A–Z") },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (filter.isBlank()) {
|
||||||
|
"$total genres"
|
||||||
|
} else {
|
||||||
|
"$shown of $total"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreRow(genre: String, trackCount: Int, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = genre,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$trackCount",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenreAlbums(
|
||||||
|
genre: String,
|
||||||
|
viewModel: BrowseViewModel,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val albums by viewModel.genreAlbums.collectAsStateWithLifecycle()
|
||||||
|
BrowseAlbumResults(
|
||||||
|
heading = genre,
|
||||||
|
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||||
|
state = albums,
|
||||||
|
emptyTitle = "No albums for this genre",
|
||||||
|
emptyBody = "The library may have been rescanned since this list was built.",
|
||||||
|
onBack = { viewModel.selectGenre(null) },
|
||||||
|
onRetry = viewModel::retryGenreAlbums,
|
||||||
|
onLoadMore = viewModel::loadMoreGenreAlbums,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared results pane for both browse axes: a back affordance, a heading, the
|
||||||
|
* album grid, and the load-more footer. Genres and Years differ only in their
|
||||||
|
* heading and copy, so the layout lives once.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongParameterList") // one presentational surface; all of it varies by axis
|
||||||
|
fun BrowseAlbumResults(
|
||||||
|
heading: String,
|
||||||
|
subtitle: String,
|
||||||
|
state: AlbumBrowseState,
|
||||||
|
emptyTitle: String,
|
||||||
|
emptyBody: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Lucide.ArrowLeft, contentDescription = "Back to the index")
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = heading,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state.failed && state.albums.isEmpty() -> ErrorRetry(
|
||||||
|
message = "Couldn't load albums.",
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
|
state.loading && state.albums.isEmpty() -> EmptyState(
|
||||||
|
title = "Loading…",
|
||||||
|
body = "",
|
||||||
|
)
|
||||||
|
state.albums.isEmpty() -> EmptyState(title = emptyTitle, body = emptyBody)
|
||||||
|
else -> BrowseAlbumGrid(
|
||||||
|
state = state,
|
||||||
|
onLoadMore = onLoadMore,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BrowseAlbumGrid(
|
||||||
|
state: AlbumBrowseState,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
// Same 176dp cell as the Albums tab, so a genre's grid and the full
|
||||||
|
// album grid line up rather than each inventing a column count.
|
||||||
|
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(items = state.albums, key = { it.id }) { album: AlbumRef ->
|
||||||
|
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
|
||||||
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (state.hasMore) {
|
||||||
|
// Explicit rather than infinite scroll, matching web: the
|
||||||
|
// remaining count is useful, and a browse axis is a place
|
||||||
|
// people skim rather than fall through.
|
||||||
|
TextButton(onClick = onLoadMore, enabled = !state.loading) {
|
||||||
|
Text(
|
||||||
|
if (state.loading) {
|
||||||
|
"Loading…"
|
||||||
|
} else {
|
||||||
|
"Load more (${state.remaining} left)"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "That's everything",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "12 albums" once the total is known, and nothing at all while the first page
|
||||||
|
* is still in flight — a count that appears as 0 and then corrects itself reads
|
||||||
|
* as a bug.
|
||||||
|
*/
|
||||||
|
internal fun albumCountLabel(total: Int, loading: Boolean, loaded: Int): String = when {
|
||||||
|
loading && loaded == 0 -> ""
|
||||||
|
total == 1 -> "1 album"
|
||||||
|
else -> "$total albums"
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import com.fabledsword.minstrel.nav.Library
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
@@ -51,8 +52,10 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
|||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Library tab. Five-tab TabBar (Artists / Albums / History / Liked /
|
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
|
||||||
* Hidden) matching `flutter_client/lib/library/library_screen.dart`.
|
* History / Liked / Hidden), matching the web client's library tab bar.
|
||||||
|
* Genres and Years arrived with #2467; the rest predate it and mirrored
|
||||||
|
* the Flutter client.
|
||||||
*
|
*
|
||||||
* Artists + Albums are wired against the existing LibraryViewModel
|
* Artists + Albums are wired against the existing LibraryViewModel
|
||||||
* (cache-first reads of cached_artists / cached_albums). The other
|
* (cache-first reads of cached_artists / cached_albums). The other
|
||||||
@@ -77,6 +80,7 @@ fun LibraryScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
@@ -113,27 +117,53 @@ fun LibraryScreen(
|
|||||||
state = pagerState,
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) { page ->
|
) { page ->
|
||||||
when (page) {
|
LibraryTabPage(page = page, viewModel = viewModel, navController = navController)
|
||||||
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
|
||||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
|
||||||
TAB_HISTORY -> HistoryTab(
|
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
|
||||||
)
|
|
||||||
TAB_LIKED -> LikedTab(navController = navController)
|
|
||||||
TAB_HIDDEN -> HiddenTab()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pager's page bodies, split out of [LibraryScreen] so the screen stays
|
||||||
|
* the scaffold + tab bar and this stays the routing table. Adding a tab is
|
||||||
|
* then one line here and one label in [LIBRARY_TABS].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LibraryTabPage(
|
||||||
|
page: Int,
|
||||||
|
viewModel: LibraryViewModel,
|
||||||
|
navController: NavHostController,
|
||||||
|
) {
|
||||||
|
when (page) {
|
||||||
|
TAB_ARTISTS -> ArtistsTab(viewModel = viewModel, navController = navController)
|
||||||
|
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||||
|
TAB_GENRES -> GenresTab(
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
)
|
||||||
|
TAB_YEARS -> YearsTab(
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
)
|
||||||
|
TAB_HISTORY -> HistoryTab(
|
||||||
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
)
|
||||||
|
TAB_LIKED -> LikedTab(navController = navController)
|
||||||
|
TAB_HIDDEN -> HiddenTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private const val TAB_ARTISTS = 0
|
private const val TAB_ARTISTS = 0
|
||||||
private const val TAB_ALBUMS = 1
|
private const val TAB_ALBUMS = 1
|
||||||
private const val TAB_HISTORY = 2
|
private const val TAB_GENRES = 2
|
||||||
private const val TAB_LIKED = 3
|
private const val TAB_YEARS = 3
|
||||||
private const val TAB_HIDDEN = 4
|
private const val TAB_HISTORY = 4
|
||||||
|
private const val TAB_LIKED = 5
|
||||||
|
private const val TAB_HIDDEN = 6
|
||||||
|
|
||||||
private val LIBRARY_TABS = listOf("Artists", "Albums", "History", "Liked", "Hidden")
|
// Genres and Years sit straight after Albums, matching the web tab bar's
|
||||||
|
// order (#2467) -- they are browse axes over the same albums, so they belong
|
||||||
|
// beside them rather than after the personal tabs.
|
||||||
|
private val LIBRARY_TABS =
|
||||||
|
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistsTab(
|
private fun ArtistsTab(
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composables.icons.lucide.Clock
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
|
||||||
|
* index, then the albums for a chosen year.
|
||||||
|
*
|
||||||
|
* Albums with no release date are absent from this axis entirely — the server
|
||||||
|
* leaves them out rather than inventing a year-0 bucket, and the empty state
|
||||||
|
* says so, because "my albums aren't here" otherwise looks like a bug.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun YearsTab(
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
viewModel: BrowseViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val selected by viewModel.selectedYear.collectAsStateWithLifecycle()
|
||||||
|
val year = selected
|
||||||
|
if (year == null) {
|
||||||
|
YearIndex(viewModel = viewModel)
|
||||||
|
} else {
|
||||||
|
YearAlbums(year = year, viewModel = viewModel, onAlbumClick = onAlbumClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun YearIndex(viewModel: BrowseViewModel) {
|
||||||
|
val state by viewModel.years.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
UiState.Loading -> EmptyState(
|
||||||
|
title = "Reading release years…",
|
||||||
|
body = "",
|
||||||
|
icon = Lucide.Clock,
|
||||||
|
)
|
||||||
|
UiState.Empty -> EmptyState(
|
||||||
|
title = "No release years found",
|
||||||
|
body = "Years come from the release date on your albums. Albums " +
|
||||||
|
"without one don't appear on this axis at all.",
|
||||||
|
icon = Lucide.Clock,
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
message = s.message,
|
||||||
|
onRetry = viewModel::loadYears,
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val decades = groupByDecade(s.data)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
decades.forEach { group ->
|
||||||
|
item(key = "decade-${group.decade}") {
|
||||||
|
DecadeHeader(decade = group.decade, albumCount = group.albumCount)
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
count = group.years.size,
|
||||||
|
key = { i -> "year-${group.years[i].year}" },
|
||||||
|
) { i ->
|
||||||
|
val row = group.years[i]
|
||||||
|
YearRow(
|
||||||
|
year = row.year,
|
||||||
|
albumCount = row.albumCount,
|
||||||
|
onClick = { viewModel.selectYear(row.year) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DecadeHeader(decade: Int, albumCount: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${decade}s",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = albumCountLabel(albumCount, loading = false, loaded = albumCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun YearRow(year: Int, albumCount: Int, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$year",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$albumCount",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun YearAlbums(
|
||||||
|
year: Int,
|
||||||
|
viewModel: BrowseViewModel,
|
||||||
|
onAlbumClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val albums by viewModel.yearAlbums.collectAsStateWithLifecycle()
|
||||||
|
BrowseAlbumResults(
|
||||||
|
heading = "$year",
|
||||||
|
subtitle = albumCountLabel(albums.total, albums.loading, albums.albums.size),
|
||||||
|
state = albums,
|
||||||
|
emptyTitle = "No albums for $year",
|
||||||
|
emptyBody = "The library may have been rescanned since this list was built.",
|
||||||
|
onBack = { viewModel.selectYear(null) },
|
||||||
|
onRetry = viewModel::retryYearAlbums,
|
||||||
|
onLoadMore = viewModel::loadMoreYearAlbums,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight reference to one album. Mirrors
|
* Lightweight reference to one album. Mirrors
|
||||||
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
|
* the Flutter client's `AlbumRef`.
|
||||||
*
|
*
|
||||||
* `coverUrl` and `durationSec` match the server contract (not
|
* `coverUrl` and `durationSec` match the server contract (not
|
||||||
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
|
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight reference to one artist. Mirrors
|
* Lightweight reference to one artist. Mirrors
|
||||||
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
|
* the Flutter client's `ArtistRef`.
|
||||||
*
|
*
|
||||||
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
|
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
|
||||||
* empty string when the artist has no representative album cover; UI code
|
* empty string when the artist has no representative album cover; UI code
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ enum class LidarrRequestKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
|
* Lidarr search hit. Mirrors the Flutter client's
|
||||||
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
|
||||||
* and `albumMbid` are filled when the row is an album/track and the
|
* and `albumMbid` are filled when the row is an album/track and the
|
||||||
* UI needs the parent IDs to build the request.
|
* UI needs the parent IDs to build the request.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain shape for one admin-issued registration invite. Mirrors
|
* Domain shape for one admin-issued registration invite. Mirrors
|
||||||
* `flutter_client/lib/models/invite.dart Invite` and the server's
|
* the Flutter client's `Invite` and the server's
|
||||||
* `inviteResp` from `internal/api/admin_invites.go`.
|
* `inviteResp` from `internal/api/admin_invites.go`.
|
||||||
*
|
*
|
||||||
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
|
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Caller's ListenBrainz integration state. Mirrors
|
* Caller's ListenBrainz integration state. Mirrors
|
||||||
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
|
* the Flutter client's `ListenBrainzStatus`
|
||||||
* and the server's `listenBrainzResp`.
|
* and the server's `listenBrainzResp`.
|
||||||
*
|
*
|
||||||
* The token itself is never read back from the server — `tokenSet`
|
* The token itself is never read back from the server — `tokenSet`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight reference to one playlist (user or system-generated).
|
* Lightweight reference to one playlist (user or system-generated).
|
||||||
* Mirrors `flutter_client/lib/models/playlist.dart`'s `Playlist`.
|
* Mirrors the Flutter client's `Playlist`.
|
||||||
*
|
*
|
||||||
* `systemVariant` discriminates user vs. system playlists — null for
|
* `systemVariant` discriminates user vs. system playlists — null for
|
||||||
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
|
* user-owned, one of "for_you" / "discover" / "songs_like_artist" / etc.
|
||||||
@@ -47,7 +47,10 @@ data class PlaylistRef(
|
|||||||
* `trackId` and `streamUrl` are nullable because the upstream track can
|
* `trackId` and `streamUrl` are nullable because the upstream track can
|
||||||
* be removed from the library while the row stays in the playlist —
|
* be removed from the library while the row stays in the playlist —
|
||||||
* those tiles render grey + unplayable per Flutter's `isAvailable`
|
* those tiles render grey + unplayable per Flutter's `isAvailable`
|
||||||
* convention.
|
* convention. [unavailable] is the second, softer case: the track is
|
||||||
|
* still there but its file is missing. Both render grey and refuse to
|
||||||
|
* play; only the second is worth explaining to the user, because it
|
||||||
|
* can fix itself.
|
||||||
*/
|
*/
|
||||||
data class PlaylistTrackRef(
|
data class PlaylistTrackRef(
|
||||||
val position: Int,
|
val position: Int,
|
||||||
@@ -59,8 +62,21 @@ data class PlaylistTrackRef(
|
|||||||
val artistName: String = "",
|
val artistName: String = "",
|
||||||
val durationSec: Int = 0,
|
val durationSec: Int = 0,
|
||||||
val streamUrl: String? = null,
|
val streamUrl: String? = null,
|
||||||
|
/**
|
||||||
|
* The track is still in the library but its file is missing from
|
||||||
|
* disk (#2527). Unlike a null [trackId] this is expected to be
|
||||||
|
* temporary — the scanner clears it when the file returns, and
|
||||||
|
* adopts the row if it returns under a new name (#2528) — so the
|
||||||
|
* row keeps its identity, its likes and its play history.
|
||||||
|
*/
|
||||||
|
val unavailable: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isAvailable: Boolean get() = trackId != null
|
/**
|
||||||
|
* Playable-ness, covering both ways a row can outlive its audio.
|
||||||
|
* Everything that greys a row or refuses to queue it reads this, so
|
||||||
|
* neither concern has to be re-derived at a call site.
|
||||||
|
*/
|
||||||
|
val isAvailable: Boolean get() = trackId != null && !unavailable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum class RequestStatus {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One Lidarr request the user has submitted. Mirrors
|
* One Lidarr request the user has submitted. Mirrors
|
||||||
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
|
* the Flutter client's `AdminRequest` —
|
||||||
* shared between the user-side `/api/requests` view and the admin
|
* shared between the user-side `/api/requests` view and the admin
|
||||||
* cross-user view since the wire shape is identical.
|
* cross-user view since the wire shape is identical.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.models
|
|||||||
/**
|
/**
|
||||||
* Caller's most recent system_playlist_runs state, driving the Home
|
* Caller's most recent system_playlist_runs state, driving the Home
|
||||||
* placeholder cards for not-yet-generated system playlists. Mirrors
|
* placeholder cards for not-yet-generated system playlists. Mirrors
|
||||||
* `flutter_client/lib/models/system_playlists_status.dart` and the
|
* the server's `systemPlaylistsStatusResp`.
|
||||||
* server's `systemPlaylistsStatusResp`.
|
|
||||||
*
|
*
|
||||||
* Zero values (inFlight=false, both timestamps null) mean the user
|
* Zero values (inFlight=false, both timestamps null) mean the user
|
||||||
* has never had a build attempted — the placeholders read as
|
* has never had a build attempted — the placeholders read as
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight reference to one track. Mirrors
|
* Lightweight reference to one track. Mirrors
|
||||||
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
|
* the Flutter client's `TrackRef`.
|
||||||
*
|
*
|
||||||
* The `Ref` suffix matches the Flutter convention — these types carry
|
* The `Ref` suffix matches the Flutter convention — these types carry
|
||||||
* only the IDs + display fields needed for list rendering + the player
|
* only the IDs + display fields needed for list rendering + the player
|
||||||
@@ -31,6 +31,16 @@ data class TrackRef(
|
|||||||
val discNumber: Int? = null,
|
val discNumber: Int? = null,
|
||||||
val durationSec: Int = 0,
|
val durationSec: Int = 0,
|
||||||
val streamUrl: String = "",
|
val streamUrl: String = "",
|
||||||
|
/**
|
||||||
|
* The server has no file for this track right now (#2704). It still
|
||||||
|
* belongs to the library, keeps its history, and may come back — but
|
||||||
|
* streaming it will fail, so nothing should queue it.
|
||||||
|
*
|
||||||
|
* NOT the same as unplayable on this device: audio already resident in
|
||||||
|
* the local cache plays regardless of what the server has, which is why
|
||||||
|
* the offline pools in ShuffleSource deliberately ignore this.
|
||||||
|
*/
|
||||||
|
val unavailable: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape returned by `GET /api/client/version`. Mirrors
|
* Wire shape returned by `GET /api/client/version`. Mirrors
|
||||||
* `flutter_client/lib/update/update_info.dart UpdateInfo`.
|
* the Flutter client's `UpdateInfo`.
|
||||||
*
|
*
|
||||||
* `version` is the server-bundled APK version (may have a leading
|
* `version` is the server-bundled APK version (may have a leading
|
||||||
* "v" from the git tag); `apkUrl` is server-relative (e.g.
|
* "v" from the git tag); `apkUrl` is server-relative (e.g.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape for `AlbumRef`. Mirrors `flutter_client/lib/models/album.dart`.
|
* Wire shape for `AlbumRef`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AlbumWire(
|
data class AlbumWire(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape for `ArtistRef`. Mirrors `flutter_client/lib/models/artist.dart`.
|
* Wire shape for `ArtistRef`.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ArtistWire(
|
data class ArtistWire(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/library/genres` (#367).
|
||||||
|
*
|
||||||
|
* The label is the file tag's own string, split on `[;,]` and trimmed but
|
||||||
|
* otherwise untouched by the server — no case folding, no synonym mapping.
|
||||||
|
* So "Rock" and "rock" can both appear, as can "Rock/Pop" beside "Rock" and
|
||||||
|
* "Pop". Don't normalise it on the client either: the index and the album
|
||||||
|
* filter have to agree on the exact string, and the filter matches what the
|
||||||
|
* server stored.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class GenreCountWire(
|
||||||
|
val genre: String = "",
|
||||||
|
@SerialName("track_count") val trackCount: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/library/years`.
|
||||||
|
*
|
||||||
|
* Albums with no release date are absent from this axis entirely rather than
|
||||||
|
* bucketed under year 0 — "unknown" is not a year, and the UI should say so
|
||||||
|
* instead of showing a fake row.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class YearCountWire(
|
||||||
|
val year: Int = 0,
|
||||||
|
@SerialName("album_count") val albumCount: Int = 0,
|
||||||
|
)
|
||||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
|||||||
/**
|
/**
|
||||||
* One row of `GET /api/lidarr/search`. Mirrors
|
* One row of `GET /api/lidarr/search`. Mirrors
|
||||||
* `web/src/lib/api/types.ts LidarrSearchResult` /
|
* `web/src/lib/api/types.ts LidarrSearchResult` /
|
||||||
* `flutter_client/lib/models/lidarr.dart LidarrSearchResult`.
|
* the Flutter client's `LidarrSearchResult`.
|
||||||
*
|
*
|
||||||
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
* `inLibrary` and `requested` let the UI greyout rows the user can't
|
||||||
* act on (already imported / already awaiting review). All defaults
|
* act on (already imported / already awaiting review). All defaults
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
|
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
|
||||||
* variants on the `type` discriminator field, mirroring
|
* variants on the `type` discriminator field.
|
||||||
* `flutter_client/lib/api/endpoints/events.dart`.
|
|
||||||
*
|
*
|
||||||
* play_started returns the server-assigned play_event_id (nullable —
|
* play_started returns the server-assigned play_event_id (nullable —
|
||||||
* server may suppress under certain conditions); the other three
|
* server may suppress under certain conditions); the other three
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
|
* Wire shape of `GET /api/home/index` — five flat slices of entity-ID
|
||||||
* strings, one per Home section. Mirrors
|
* strings, one per Home section. Mirrors the server's
|
||||||
* `flutter_client/lib/models/home_index.dart` (and the server's
|
* `HomeIndexPayload` in `internal/api/types.go`.
|
||||||
* `internal/api/types.go HomeIndexPayload`).
|
|
||||||
*
|
*
|
||||||
* Section name implies entity type; no per-entry type tag is needed:
|
* Section name implies entity type; no per-entry type tag is needed:
|
||||||
* - recentlyAddedAlbums → album
|
* - recentlyAddedAlbums → album
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape for `GET /api/me` and the return value of
|
* Wire shape for `GET /api/me` and the return value of
|
||||||
* `PUT /api/me/profile`. Mirrors
|
* `PUT /api/me/profile`. Two things the shape assumes:
|
||||||
* `flutter_client/lib/models/my_profile.dart`:
|
|
||||||
* - `display_name` and `email` are nullable; server returns null
|
* - `display_name` and `email` are nullable; server returns null
|
||||||
* when the user hasn't set them yet (registration only requires
|
* when the user hasn't set them yet (registration only requires
|
||||||
* a username).
|
* a username).
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ data class PlaylistsListWire(
|
|||||||
* / `artistId` / `streamUrl` are nullable because the upstream track
|
* / `artistId` / `streamUrl` are nullable because the upstream track
|
||||||
* may have been removed from the library while the row stays in the
|
* may have been removed from the library while the row stays in the
|
||||||
* playlist with its display fields preserved.
|
* playlist with its display fields preserved.
|
||||||
|
*
|
||||||
|
* [unavailable] is the other way a row outlives its audio (#2527): the
|
||||||
|
* track is still in the library, with its history and likes, but its
|
||||||
|
* file is missing from disk. The server withholds `stream_url` in that
|
||||||
|
* case too, so a client that only checked the URL would already skip
|
||||||
|
* it — the flag is what lets the UI say WHY instead of rendering a
|
||||||
|
* mysteriously dead row. Defaults false so a server that predates the
|
||||||
|
* field deserialises cleanly.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlaylistTrackWire(
|
data class PlaylistTrackWire(
|
||||||
@@ -53,6 +61,7 @@ data class PlaylistTrackWire(
|
|||||||
@SerialName("artist_name") val artistName: String = "",
|
@SerialName("artist_name") val artistName: String = "",
|
||||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||||
@SerialName("stream_url") val streamUrl: String? = null,
|
@SerialName("stream_url") val streamUrl: String? = null,
|
||||||
|
val unavailable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of `GET /api/quarantine/mine`. Mirrors
|
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||||
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
|
* the Flutter client's `QuarantineMineRow`
|
||||||
* (web `LidarrQuarantineMineRow`).
|
* (web `LidarrQuarantineMineRow`).
|
||||||
*
|
*
|
||||||
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
|
|||||||
* Wire shape of `requestView` from `internal/api/requests.go` — the
|
* Wire shape of `requestView` from `internal/api/requests.go` — the
|
||||||
* row returned by both `GET /api/requests` (caller's own requests) and
|
* row returned by both `GET /api/requests` (caller's own requests) and
|
||||||
* `GET /api/admin/requests` (cross-user admin view). Mirrors
|
* `GET /api/admin/requests` (cross-user admin view). Mirrors
|
||||||
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
|
* the Flutter client's `AdminRequest`.
|
||||||
*
|
*
|
||||||
* Status values: `pending` / `approved` / `rejected` / `completed` /
|
* Status values: `pending` / `approved` / `rejected` / `completed` /
|
||||||
* `failed`. Kind values: `artist` / `album` / `track`.
|
* `failed`. Kind values: `artist` / `album` / `track`.
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ data class SyncTrackWire(
|
|||||||
@SerialName("file_path") val filePath: String? = null,
|
@SerialName("file_path") val filePath: String? = null,
|
||||||
@SerialName("file_format") val fileFormat: String? = null,
|
@SerialName("file_format") val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
|
// The file is currently absent from disk server-side (#2704). Shipped as
|
||||||
|
// state rather than the row being withheld, because a missing file is
|
||||||
|
// expected to return — dropping it would churn the cache on every
|
||||||
|
// transient unmount and discard the identity #2528 preserves.
|
||||||
|
//
|
||||||
|
// Defaults false so a server predating the field deserialises cleanly and
|
||||||
|
// its tracks stay playable, which is the correct reading of "this server
|
||||||
|
// has nothing to say about missing files".
|
||||||
|
val missing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire shape for `TrackRef` as the server emits it. Mirrors
|
* Wire shape for `TrackRef` as the server emits it. Mirrors
|
||||||
* `flutter_client/lib/models/track.dart`'s `TrackRef.fromJson`
|
* the Flutter client's `TrackRef.fromJson`
|
||||||
* field-for-field; the keys are snake_case because the server is Go
|
* field-for-field; the keys are snake_case because the server is Go
|
||||||
* (json:"album_id" etc.).
|
* (json:"album_id" etc.).
|
||||||
*
|
*
|
||||||
@@ -26,4 +26,9 @@ data class TrackWire(
|
|||||||
@SerialName("disc_number") val discNumber: Int? = null,
|
@SerialName("disc_number") val discNumber: Int? = null,
|
||||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||||
@SerialName("stream_url") val streamUrl: String = "",
|
@SerialName("stream_url") val streamUrl: String = "",
|
||||||
|
// Omitted by the server when false, so the default carries most rows
|
||||||
|
// (#2704). True only from the direct-lookup surfaces — album detail and
|
||||||
|
// search — which return a track the user asked for by name or container
|
||||||
|
// rather than one Minstrel chose.
|
||||||
|
val unavailable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import javax.inject.Singleton
|
|||||||
* Pre-downloads the next-N tracks in the queue into the shared Media3
|
* Pre-downloads the next-N tracks in the queue into the shared Media3
|
||||||
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
|
* [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or
|
||||||
* natural advance plays from disk instead of waiting on a fresh HTTP
|
* natural advance plays from disk instead of waiting on a fresh HTTP
|
||||||
* connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart):
|
* connection. Behaviour:
|
||||||
* watches the player's current track, walks forward by
|
* watches the player's current track, walks forward by
|
||||||
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
|
* [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow]
|
||||||
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
|
* tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.connectivity.ServerHealth
|
|||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -71,12 +72,18 @@ class MinstrelForwardingPlayer(
|
|||||||
private val castNetworkLock: CastNetworkLock,
|
private val castNetworkLock: CastNetworkLock,
|
||||||
private val networkStatus: NetworkStatusController,
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
|
private val onStalled: (trackId: String) -> Unit = {},
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val handler = Handler(delegate.applicationLooper)
|
private val handler = Handler(delegate.applicationLooper)
|
||||||
private var pollJob: Job? = null
|
private var pollJob: Job? = null
|
||||||
|
|
||||||
|
// Watches for the renderer stopping without being asked to. A UPnP
|
||||||
|
// renderer streams on its own, so a stream that dies looks like silence
|
||||||
|
// and nothing else in the app would notice -- see [RemoteStallWatchdog].
|
||||||
|
private val stallWatchdog = RemoteStallWatchdog()
|
||||||
|
|
||||||
// Tracks consecutive non-PLAYING poll observations so a single transient
|
// Tracks consecutive non-PLAYING poll observations so a single transient
|
||||||
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
||||||
// flip the play/pause button. Manual pause still feels instant because it
|
// flip the play/pause button. Manual pause still feels instant because it
|
||||||
@@ -492,6 +499,9 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
castNetworkLock.release()
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
|
// The next cast starts with a clean attempt budget; a stall on the
|
||||||
|
// route we just left says nothing about the next one.
|
||||||
|
stallWatchdog.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +594,63 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||||
}
|
}
|
||||||
|
checkForStall(active, info.trackUri, transport)
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||||
|
*
|
||||||
|
* Recovery re-issues Play and then seeks back to the last position the
|
||||||
|
* renderer was observed playing, so a stream that died 90 seconds into a
|
||||||
|
* track resumes near there rather than restarting it. The seek is
|
||||||
|
* best-effort and deliberately after the play: a renderer that refuses
|
||||||
|
* the seek is still better off playing from zero than silent.
|
||||||
|
*/
|
||||||
|
private suspend fun checkForStall(
|
||||||
|
active: ActiveUpnp,
|
||||||
|
trackUri: String,
|
||||||
|
transport: TransportInfo,
|
||||||
|
) {
|
||||||
|
val decision = stallWatchdog.onPoll(
|
||||||
|
trackUri = trackUri,
|
||||||
|
state = transport.state,
|
||||||
|
statusOk = transport.statusOk,
|
||||||
|
playIntent = remoteState.lastPlayIntent,
|
||||||
|
positionMs = remoteState.positionMs,
|
||||||
|
nowMs = SystemClock.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
when (decision) {
|
||||||
|
is RemoteStallWatchdog.Decision.Resume -> {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP stall on %s: renderer stopped unasked (status_ok=%b), " +
|
||||||
|
"resume attempt %d at %dms",
|
||||||
|
active.routeName, transport.statusOk, decision.attempt, decision.resumeAtMs,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
retryTransport { active.avTransport.play() }
|
||||||
|
if (decision.resumeAtMs > 0L) {
|
||||||
|
retryTransport { active.avTransport.seek(decision.resumeAtMs) }
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
// Leave the streak alone: a failed recovery is more
|
||||||
|
// evidence of a stall, and the next poll re-decides.
|
||||||
|
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemoteStallWatchdog.Decision.GiveUp -> {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP stall on %s: giving up after repeated resume attempts",
|
||||||
|
active.routeName,
|
||||||
|
)
|
||||||
|
// Tell the user and the admin inbox. Silence here would be the
|
||||||
|
// original bug: playback simply ends and nobody finds out.
|
||||||
|
trackIdFromStreamUri(trackUri)?.let { handler.post { onStalled(it) } }
|
||||||
|
}
|
||||||
|
RemoteStallWatchdog.Decision.None -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Align the paused local delegate cursor to the track the renderer is
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
* actually playing, so the un-overridden current-item getters
|
* actually playing, so the un-overridden current-item getters
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ private const val DEBOUNCE_MS = 2_000L
|
|||||||
* operator never finds out the track is bad and the next user hits
|
* operator never finds out the track is bad and the next user hits
|
||||||
* the same wall.
|
* the same wall.
|
||||||
*
|
*
|
||||||
* The snackbar text mirrors Flutter's `playback_error_reporter.dart`:
|
* The snackbar text:
|
||||||
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
|
* collect [PlayerController.playbackErrorEvents], debounce in a 2s
|
||||||
* window, emit "Couldn't play 'X' — skipping" for a single error or
|
* window, emit "Couldn't play 'X' — skipping" for a single error or
|
||||||
* "Skipped N unplayable tracks" when a burst lands inside the window.
|
* "Skipped N unplayable tracks" when a burst lands inside the window.
|
||||||
|
|||||||
@@ -128,6 +128,28 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var queueRefs: List<TrackRef> = emptyList()
|
private var queueRefs: List<TrackRef> = emptyList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// A remote stall that survived the watchdog's retries is a playback
|
||||||
|
// failure like any other: the user gets the snackbar and the operator
|
||||||
|
// gets an admin-inbox row, via the same reporter that handles dead
|
||||||
|
// files. Without this the session just ends in silence -- the exact
|
||||||
|
// failure the watchdog exists to surface.
|
||||||
|
scope.launch {
|
||||||
|
playerFactory.stallEvents.collect { trackId ->
|
||||||
|
val title = queueRefs.firstOrNull { it.id == trackId }?.title
|
||||||
|
?.takeIf { it.isNotEmpty() } ?: "Track"
|
||||||
|
playbackErrorEventsChannel.trySend(
|
||||||
|
PlaybackErrorEvent(
|
||||||
|
trackId = trackId,
|
||||||
|
kind = "stalled",
|
||||||
|
title = title,
|
||||||
|
detail = "remote renderer stopped and would not resume",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completes when [mediaController] is non-null and the listener has
|
* Completes when [mediaController] is non-null and the listener has
|
||||||
* been attached. Used by [awaitReady] so cold-boot callers like
|
* been attached. Used by [awaitReady] so cold-boot callers like
|
||||||
@@ -238,8 +260,16 @@ class PlayerController @Inject constructor(
|
|||||||
autoplay: Boolean = true,
|
autoplay: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val controller = mediaController ?: return
|
val controller = mediaController ?: return
|
||||||
queueRefs = tracks
|
// One choke point for #2704: a track whose file the server has lost
|
||||||
val items = tracks.map { it.toMediaItem(source) }
|
// must not take a queue slot, whichever surface built the list.
|
||||||
|
// Playlists already drop them earlier (toPlayableTrackRefs), but
|
||||||
|
// album play-all, search, radio and cold-boot resume all arrive here
|
||||||
|
// too, and catching it once beats remembering at five call sites.
|
||||||
|
val playable = dropUnavailable(tracks, initialIndex)
|
||||||
|
if (playable.tracks.isEmpty()) return
|
||||||
|
queueRefs = playable.tracks
|
||||||
|
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||||
|
val startIndex = playable.initialIndex
|
||||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||||
// context after awaitReady() unblocks (ResumeController launches
|
// context after awaitReady() unblocks (ResumeController launches
|
||||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||||
@@ -248,7 +278,7 @@ class PlayerController @Inject constructor(
|
|||||||
// if we're already there, run directly to avoid the re-dispatch
|
// if we're already there, run directly to avoid the re-dispatch
|
||||||
// latency UI callers depend on.
|
// latency UI callers depend on.
|
||||||
runOnControllerThread(controller) {
|
runOnControllerThread(controller) {
|
||||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
controller.setMediaItems(items, startIndex, /* startPositionMs = */ 0L)
|
||||||
controller.prepare()
|
controller.prepare()
|
||||||
if (autoplay) controller.play()
|
if (autoplay) controller.play()
|
||||||
}
|
}
|
||||||
@@ -852,3 +882,37 @@ data class PlaybackErrorEvent(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A queue with the server-missing tracks removed, and the caller's starting
|
||||||
|
* index moved to match (#2704).
|
||||||
|
*/
|
||||||
|
data class PlayableQueue(val tracks: List<TrackRef>, val initialIndex: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop tracks the server has no file for, keeping [initialIndex] pointing at
|
||||||
|
* the same music.
|
||||||
|
*
|
||||||
|
* The index is the fiddly half and the reason this is a function rather than
|
||||||
|
* a `filter` at the call site: removing entries before the requested position
|
||||||
|
* would otherwise start playback on the wrong track. The new index is the
|
||||||
|
* count of surviving tracks ahead of it, which also gives the right behaviour
|
||||||
|
* when the requested track is ITSELF missing — playback starts at the next
|
||||||
|
* one that can play, i.e. it gets skipped.
|
||||||
|
*
|
||||||
|
* Returns an empty queue when nothing survives, which the caller treats as
|
||||||
|
* "don't touch the player": replacing a playing queue with silence because a
|
||||||
|
* stale list turned out to be entirely missing would be worse than ignoring
|
||||||
|
* the request.
|
||||||
|
*/
|
||||||
|
fun dropUnavailable(tracks: List<TrackRef>, initialIndex: Int): PlayableQueue {
|
||||||
|
if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex)
|
||||||
|
val kept = ArrayList<TrackRef>(tracks.size)
|
||||||
|
var newIndex = 0
|
||||||
|
tracks.forEachIndexed { i, track ->
|
||||||
|
if (track.unavailable) return@forEachIndexed
|
||||||
|
if (i < initialIndex) newIndex++
|
||||||
|
kept.add(track)
|
||||||
|
}
|
||||||
|
return PlayableQueue(kept, newIndex.coerceAtMost((kept.size - 1).coerceAtLeast(0)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class PlayerFactory @Inject constructor(
|
|||||||
)
|
)
|
||||||
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
||||||
|
|
||||||
|
// Track ids whose remote playback stalled and could not be resumed. Same
|
||||||
|
// buffering rationale as dropEvents: a burst is one problem, not N.
|
||||||
|
private val stallEventsInternal = MutableSharedFlow<String>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
|
||||||
|
|
||||||
fun build(): Player {
|
fun build(): Player {
|
||||||
val exo = buildExoPlayer()
|
val exo = buildExoPlayer()
|
||||||
return MinstrelForwardingPlayer(
|
return MinstrelForwardingPlayer(
|
||||||
@@ -84,6 +93,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
castNetworkLock = CastNetworkLock(context),
|
castNetworkLock = CastNetworkLock(context),
|
||||||
networkStatus = serverHealth,
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
|
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.player
|
|||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cycle on the repeat button: off → all → one → off. Mirrors
|
* Cycle on the repeat button: off → all → one → off. Maps directly to
|
||||||
* `AudioServiceRepeatMode` in flutter_client and maps directly to
|
|
||||||
* the three Media3 `Player.REPEAT_MODE_*` int constants.
|
* the three Media3 `Player.REPEAT_MODE_*` int constants.
|
||||||
*/
|
*/
|
||||||
enum class RepeatMode { OFF, ALL, ONE }
|
enum class RepeatMode { OFF, ALL, ONE }
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notices when a UPnP renderer has stopped playing without being asked, and
|
||||||
|
* decides whether to try getting it going again.
|
||||||
|
*
|
||||||
|
* The gap this closes (diagnostics 2026-08-16): a Sonos playing from the
|
||||||
|
* server stopped by itself mid-track while the phone was in Doze. The poll
|
||||||
|
* loop was frozen, so nothing saw it; when the screen came back on the app
|
||||||
|
* faithfully reported "queue track 10, position 113s, not playing" and then
|
||||||
|
* sat there. Playback was over and no part of the app considered that a
|
||||||
|
* problem. A renderer streams autonomously, which is exactly why a failed
|
||||||
|
* stream is invisible without something watching for it.
|
||||||
|
*
|
||||||
|
* Deliberately conservative about what counts as a stall:
|
||||||
|
*
|
||||||
|
* - Only STOPPED (or a transport reporting an error) triggers recovery.
|
||||||
|
* PAUSED is left alone: the likely cause is a person pausing from the
|
||||||
|
* Sonos app or a wall controller, and fighting them for the transport is
|
||||||
|
* obnoxious. A stream that dies stops, it does not pause.
|
||||||
|
* - Only when the operator's last intent was to play. A stop we asked for
|
||||||
|
* is not a stall.
|
||||||
|
* - Only after [STALL_CONFIRM_POLLS] consecutive polls agree, so a single
|
||||||
|
* reading during a track change (Sonos passes through STOPPED and
|
||||||
|
* TRANSITIONING between queue items) never trips it.
|
||||||
|
* - At most [MAX_RESUME_ATTEMPTS] per track, spaced by
|
||||||
|
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
|
||||||
|
* infinite retry loop against the renderer.
|
||||||
|
*
|
||||||
|
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
|
||||||
|
* loop and performs the transport calls, this only says what should happen.
|
||||||
|
* That keeps the awkward part — counting, keying and giving up — testable
|
||||||
|
* without a renderer.
|
||||||
|
*/
|
||||||
|
class RemoteStallWatchdog {
|
||||||
|
|
||||||
|
sealed interface Decision {
|
||||||
|
/** Nothing to do. */
|
||||||
|
data object None : Decision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the renderer to play again. [resumeAtMs] is the last position
|
||||||
|
* observed while it was actually playing, so the caller can seek back
|
||||||
|
* to roughly where the listener was rather than restarting the track.
|
||||||
|
*/
|
||||||
|
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
|
||||||
|
|
||||||
|
/** Attempts are exhausted. Report it and stop trying for this track. */
|
||||||
|
data object GiveUp : Decision
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trackKey: String = ""
|
||||||
|
private var lastPlayingPositionMs: Long = 0L
|
||||||
|
private var stoppedStreak: Int = 0
|
||||||
|
private var attempts: Int = 0
|
||||||
|
private var lastAttemptAtMs: Long = 0L
|
||||||
|
private var gaveUp: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one poll result in, get the action out.
|
||||||
|
*
|
||||||
|
* @param trackUri the renderer's current track URI — identity for the
|
||||||
|
* per-track attempt budget, so moving to the next track forgives a
|
||||||
|
* previous one's failures.
|
||||||
|
* @param statusOk the transport's own status flag: false means the
|
||||||
|
* renderer is reporting an error rather than merely being stopped.
|
||||||
|
* @param playIntent the operator's last play/pause intent.
|
||||||
|
* @param nowMs a monotonic clock (SystemClock.elapsedRealtime), passed in
|
||||||
|
* so tests can drive time.
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount") // early returns per state are clearer than nesting
|
||||||
|
fun onPoll(
|
||||||
|
trackUri: String,
|
||||||
|
state: TransportState,
|
||||||
|
statusOk: Boolean,
|
||||||
|
playIntent: Boolean,
|
||||||
|
positionMs: Long,
|
||||||
|
nowMs: Long,
|
||||||
|
): Decision {
|
||||||
|
if (trackUri != trackKey) {
|
||||||
|
// New track: a fresh attempt budget, and no inherited stall state.
|
||||||
|
trackKey = trackUri
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
gaveUp = false
|
||||||
|
lastPlayingPositionMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playIntent) {
|
||||||
|
// Stopped because we asked. Not a stall, and the next genuine one
|
||||||
|
// should start from a clean budget.
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
gaveUp = false
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == TransportState.PLAYING && statusOk) {
|
||||||
|
lastPlayingPositionMs = positionMs
|
||||||
|
resetStall()
|
||||||
|
// A track that recovered and is playing again has earned back its
|
||||||
|
// budget; a later, unrelated stall on the same track should get
|
||||||
|
// the full set of attempts rather than the remainder.
|
||||||
|
attempts = 0
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
val stalled = state == TransportState.STOPPED || !statusOk
|
||||||
|
if (!stalled) {
|
||||||
|
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
|
||||||
|
// flight). Neither is a stall; drop the streak so a mid-track
|
||||||
|
// transition doesn't accumulate toward one.
|
||||||
|
resetStall()
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
stoppedStreak += 1
|
||||||
|
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
|
||||||
|
if (gaveUp) return Decision.None
|
||||||
|
|
||||||
|
if (attempts >= MAX_RESUME_ATTEMPTS) {
|
||||||
|
gaveUp = true
|
||||||
|
return Decision.GiveUp
|
||||||
|
}
|
||||||
|
if (attempts > 0 && nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
lastAttemptAtMs = nowMs
|
||||||
|
return Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget everything — call when the route changes or playback is torn down. */
|
||||||
|
fun reset() {
|
||||||
|
trackKey = ""
|
||||||
|
lastPlayingPositionMs = 0L
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
lastAttemptAtMs = 0L
|
||||||
|
gaveUp = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetStall() {
|
||||||
|
stoppedStreak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// At the 1s poll cadence this is ~3s of agreement. Sonos passes
|
||||||
|
// through STOPPED between queue items, so one or two readings mean
|
||||||
|
// nothing on their own.
|
||||||
|
const val STALL_CONFIRM_POLLS = 3
|
||||||
|
|
||||||
|
// Three tries at ~5s spacing covers a server blip or a dropped
|
||||||
|
// connection without hammering a renderer whose file is simply bad.
|
||||||
|
const val MAX_RESUME_ATTEMPTS = 3
|
||||||
|
const val RETRY_SPACING_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,7 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the player's last queue + position to Room so a torn-down
|
* Persists the player's last queue + position to Room so a torn-down
|
||||||
* session can resume on next app launch. Mirrors
|
* session can resume on next app launch.
|
||||||
* `flutter_client/lib/cache/resume_controller.dart`.
|
|
||||||
*
|
*
|
||||||
* Subscribes to [PlayerController.uiState] in init; persists when the
|
* Subscribes to [PlayerController.uiState] in init; persists when the
|
||||||
* (track-id, queueIndex) changes — captures real session transitions
|
* (track-id, queueIndex) changes — captures real session transitions
|
||||||
|
|||||||
+14
-2
@@ -225,7 +225,14 @@ class AVTransportClient(
|
|||||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||||
else -> TransportState.UNKNOWN
|
else -> TransportState.UNKNOWN
|
||||||
}
|
}
|
||||||
return TransportInfo(state)
|
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
||||||
|
// healthy, and it is the one unambiguous way to tell "the stream died"
|
||||||
|
// from "somebody pressed stop" — both of which land in STOPPED. The
|
||||||
|
// spec defines OK and ERROR_OCCURRED; anything unrecognised (or absent,
|
||||||
|
// which some renderers do) is read as OK so a quiet device is never
|
||||||
|
// treated as a broken one.
|
||||||
|
val statusOk = result["CurrentTransportStatus"]?.let { it != "ERROR_OCCURRED" } ?: true
|
||||||
|
return TransportInfo(state, statusOk)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||||
@@ -293,4 +300,9 @@ data class PositionInfo(
|
|||||||
|
|
||||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||||
|
|
||||||
data class TransportInfo(val state: TransportState)
|
/**
|
||||||
|
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
||||||
|
* that only care about [state] read unchanged and an older/quieter renderer is
|
||||||
|
* never mistaken for a failing one.
|
||||||
|
*/
|
||||||
|
data class TransportInfo(val state: TransportState, val statusOk: Boolean = true)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ private const val GRADIENT_TWEEN_MS = 600
|
|||||||
* The held color is NOT reset when [coverUrl] changes — it stays on
|
* The held color is NOT reset when [coverUrl] changes — it stays on
|
||||||
* the previous track's dominant until the new palette resolves, so the
|
* the previous track's dominant until the new palette resolves, so the
|
||||||
* gradient tweens old→new directly instead of dipping toward the
|
* gradient tweens old→new directly instead of dipping toward the
|
||||||
* fallback mid-swap. Mirrors `now_playing_screen.dart`'s preload-then-
|
* fallback mid-swap — preload-then-swap, keeping the previous dominant
|
||||||
* swap ("keep the previous dominant"); the cover image swaps smoothly
|
* until the new one resolves. The cover image swaps smoothly
|
||||||
* via `CoverPrefetcher`, which warms the next track's bytes into Coil.
|
* via `CoverPrefetcher`, which warms the next track's bytes into Coil.
|
||||||
*
|
*
|
||||||
* Starts at [Color.Transparent] (cold mount) and resets to it only
|
* Starts at [Color.Transparent] (cold mount) and resets to it only
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ private const val COVER_SIZE_DP = 48
|
|||||||
private const val SCRUBBER_ROW_HEIGHT_DP = 4
|
private const val SCRUBBER_ROW_HEIGHT_DP = 4
|
||||||
|
|
||||||
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
|
// Upward flick speed (dp/s) that expands the bar into NowPlaying.
|
||||||
// Mirrors player_bar.dart's 200 px/s threshold; expressed in dp and
|
// The 200 px/s threshold carries over from the Flutter player bar,
|
||||||
// converted via density so the gesture feels the same across screens.
|
// expressed in dp and converted via density so the gesture feels the
|
||||||
|
// same across screens.
|
||||||
private const val SWIPE_UP_VELOCITY_DP = 200
|
private const val SWIPE_UP_VELOCITY_DP = 200
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@@ -129,7 +130,7 @@ fun MiniPlayer(
|
|||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
// Swipe up anywhere on the bar to expand into the full player —
|
// Swipe up anywhere on the bar to expand into the full player —
|
||||||
// mirrors player_bar.dart. We only act on a clear upward flick
|
// We only act on a clear upward flick
|
||||||
// (negative velocity past the threshold) so a slow tap-with-jitter
|
// (negative velocity past the threshold) so a slow tap-with-jitter
|
||||||
// doesn't accidentally open the screen. The horizontal seek slider
|
// doesn't accidentally open the screen. The horizontal seek slider
|
||||||
// keeps its own gestures; a vertical draggable only claims
|
// keeps its own gestures; a vertical draggable only claims
|
||||||
|
|||||||
@@ -371,9 +371,7 @@ private fun NowPlayingContent(
|
|||||||
TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
|
TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
// Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
|
// Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
|
||||||
// scrubber — Flutter's _SecondaryControls placement
|
// scrubber. Android previously had it below the transport row.
|
||||||
// (now_playing_screen.dart:464). Android previously had it below
|
|
||||||
// the transport row.
|
|
||||||
BottomActionsRow(
|
BottomActionsRow(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
track = track,
|
track = track,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -88,7 +88,7 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
// Reconcile: BuildSystemPlaylists rotates system-playlist
|
// Reconcile: BuildSystemPlaylists rotates system-playlist
|
||||||
// UUIDs every rebuild, so upsert alone leaves stale rows
|
// UUIDs every rebuild, so upsert alone leaves stale rows
|
||||||
// whose detail fetch 404s ("That playlist no longer
|
// whose detail fetch 404s ("That playlist no longer
|
||||||
// exists"). Mirrors playlists_provider.dart's deleteWhere.
|
// exists").
|
||||||
playlistDao.replaceList(
|
playlistDao.replaceList(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
freshOwnedIds = wire.owned.map { it.id },
|
freshOwnedIds = wire.owned.map { it.id },
|
||||||
@@ -111,8 +111,7 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
api.get(id)
|
api.get(id)
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
// Server says this playlist is gone — drop the stale cache
|
// Server says this playlist is gone — drop the stale cache
|
||||||
// row so the list stops showing it. Mirrors
|
// row so the list stops showing it.
|
||||||
// playlists_provider.dart's deleteWhere on detail failure.
|
|
||||||
if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
|
if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||||
playlistDao.deleteByIds(listOf(id))
|
playlistDao.deleteByIds(listOf(id))
|
||||||
}
|
}
|
||||||
@@ -326,4 +325,5 @@ private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
|
|||||||
artistName = artistName,
|
artistName = artistName,
|
||||||
durationSec = durationSec,
|
durationSec = durationSec,
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
|
unavailable = unavailable,
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user