Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d009b34e2 | |||
| 5fc04f14b7 | |||
| a09b636e1a | |||
| 356f8f5d6c | |||
| 96aa2407d9 | |||
| e856172d60 | |||
| ab62a3d118 | |||
| a3c0aed63e | |||
| 1ddde12959 | |||
| f1b4652c77 | |||
| 261b44522d | |||
| 6f20a75f9b | |||
| 2d5f0691c2 | |||
| e8a515dac4 | |||
| acc7149537 | |||
| 4ede37d9ad | |||
| 4bd069430b | |||
| 22152b1ba3 | |||
| 572325e23f | |||
| 8d466ebdd5 | |||
| af5744f8ab | |||
| c7549bbe48 | |||
| 9cac664679 | |||
| c08f4ace80 | |||
| c1df2af992 | |||
| a7f35a5d6d | |||
| e610948307 | |||
| 8c00ee21c7 | |||
| 2500914498 | |||
| fb811804d2 | |||
| 0134281b8c | |||
| 4dbb3190ff | |||
| 2299824ad9 | |||
| a77d4ceac0 | |||
| 11c40c6aca | |||
| 37134950a5 | |||
| f4d07ef9a1 | |||
| 74cc76b369 | |||
| a65474284a | |||
| 2f67c26a45 | |||
| ab8a86e794 | |||
| 727a0760da | |||
| 983a9d92be | |||
| 21ab0d78bb | |||
| 107abda97e | |||
| 2b033131e0 | |||
| f88e82a777 | |||
| 3c0806d8fd | |||
| 4621846ec4 | |||
| d4d936ee57 | |||
| b6b73fdd0c | |||
| 2a5b6970e9 | |||
| 59427bcf2f | |||
| 163c1174db | |||
| 3162fedd3b | |||
| a05a279508 | |||
| a3985f1138 | |||
| 7cabe4efef | |||
| d703fc27f8 | |||
| f65650fb6d | |||
| 51afc992d4 | |||
| a8e6e1c2f7 | |||
| 19061cd10c | |||
| b2d1a9ea10 | |||
| fcded9294c | |||
| c3ecd1bec8 | |||
| 4c4399c9bb | |||
| e16bdd9546 | |||
| 5054c46385 | |||
| 42abb7adff | |||
| e4578593d1 | |||
| 04933f2d9f | |||
| 154f415f92 | |||
| 2435ef7961 | |||
| f6e27cd39a | |||
| f3378dd610 | |||
| 14d7678624 | |||
| e57a53a92e | |||
| 02d9f39845 | |||
| a2bea8601a | |||
| 27f123f7d9 | |||
| 0db7054518 | |||
| 861dd8ef17 | |||
| 7bc254e17e | |||
| 1daf0b7fdf | |||
| 308202a1df | |||
| 9b4f907db6 | |||
| 993bcc6a14 | |||
| 2ec54bc429 | |||
| 7e8d19606a | |||
| dd67f28745 | |||
| 6e0223a9b1 | |||
| 0eaaf66748 | |||
| 633406c05b | |||
| 2357df83be | |||
| e8eff1baa1 | |||
| 714039bae5 | |||
| 65057473c6 | |||
| 94727f40bb | |||
| 98fdd9dcdf | |||
| 08c16d6d86 | |||
| 8a6c926ea7 | |||
| fc83c7b514 | |||
| 947d4401a6 | |||
| 4eb9935f8e | |||
| 2940e95b64 | |||
| a3db6c9453 | |||
| 1b223d3891 | |||
| 0d171490c3 | |||
| 5114a8118c | |||
| fd4ce402d7 | |||
| 153e931490 | |||
| 536350380f | |||
| ab58f3ffcb | |||
| 16669a0365 | |||
| 2bcbb53c49 | |||
| 326b7008a9 | |||
| 8d5c90e0ed | |||
| 9c7dec60a9 | |||
| 6fb6729beb | |||
| e0c5789cee | |||
| 0fa7dc7982 | |||
| 6bd8a15c7a | |||
| 9bf4b504b3 | |||
| 3959c85111 | |||
| cb35133843 | |||
| 084c202654 | |||
| 3ffd9beca0 | |||
| 3f8a2c511a | |||
| 1536860e59 | |||
| 268e12a5eb | |||
| 16e0e98943 | |||
| f03a0042e8 | |||
| e266a0f2dc | |||
| e0cf527304 | |||
| ed6d0936ca | |||
| d5983433c5 | |||
| df4cb434b2 | |||
| 857a8621ec | |||
| 7f395024a0 | |||
| 84a638a41f | |||
| dd848e76e8 | |||
| 0dbb662b4c | |||
| ccebd98735 | |||
| 231f1ddf9a | |||
| 143130eaae | |||
| 56f0557d94 | |||
| 6dc488652e | |||
| caa19f2d91 | |||
| 66ddd3c366 | |||
| b1f62a0733 | |||
| 25ff7afc33 | |||
| c8baaa5329 | |||
| 36006d703d | |||
| 2944288050 | |||
| 5f239f05a5 | |||
| 2499449c0b | |||
| 5159bcd3f4 | |||
| fdaa1a0472 | |||
| 534dafb044 | |||
| d70075c426 | |||
| b6d6f22598 | |||
| 78ad8a31e7 | |||
| d3a05520d3 | |||
| 17ff69780d | |||
| a2f6bdb44f | |||
| 8fb82d473d | |||
| a7fcafadda | |||
| 3ac8cc9363 | |||
| dccdb00bce | |||
| 078520f504 | |||
| d872a30506 | |||
| 5613fec5ef | |||
| 28b07c7ef4 | |||
| 83df3773ae | |||
| 088e8f4bf8 | |||
| c47ec6093f | |||
| bca8622640 | |||
| abb384f479 | |||
| 22f03a5fe8 | |||
| 0bc17a85fb | |||
| 602ef3bfdf | |||
| 751c2878f7 | |||
| 626fc7502c | |||
| c4cccea775 | |||
| 9bf3b8a2f2 | |||
| d5c8d316c5 | |||
| 9f6ceb1731 | |||
| afcaf56b9c | |||
| 7d207e56f5 | |||
| 3e905db906 | |||
| bd1bcab12b | |||
| 8bb192fe54 | |||
| 408eed1f3d | |||
| 6b5d12f3a1 | |||
| 372e7eca86 | |||
| 79d00ba001 | |||
| 2b595c40cd | |||
| dfc08650e7 | |||
| fc0350cb96 | |||
| 5546787f78 | |||
| 6564d37a2a | |||
| 492460cf4a | |||
| c5dc3bd256 | |||
| 3f2822dfc6 | |||
| dfbeed01a6 | |||
| 1ad67dbe59 | |||
| da1cb7b590 | |||
| 66545bf8c3 | |||
| 5541171e94 | |||
| 147d6e280e | |||
| 1b3f2e254b |
@@ -56,6 +56,12 @@ jobs:
|
||||
- name: Pub get
|
||||
run: flutter pub get
|
||||
|
||||
- name: Codegen (drift, etc.)
|
||||
# build_runner generates *.g.dart files (drift schemas, etc.)
|
||||
# which are gitignored. Must run before analyze + test so the
|
||||
# generated symbols exist.
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --fatal-infos
|
||||
|
||||
@@ -66,9 +72,44 @@ jobs:
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Decode signing keystore
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Reconstructs the release keystore from a base64-encoded
|
||||
# CI secret so every tagged build is signed with the same
|
||||
# key. Without this, each CI runner would generate its own
|
||||
# debug keystore and Android would refuse to upgrade an
|
||||
# existing install (signature mismatch).
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
run: |
|
||||
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
|
||||
exit 1
|
||||
fi
|
||||
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: flutter build apk --release
|
||||
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||
# so PackageInfo.version matches what /api/client/version
|
||||
# reports — otherwise the in-app update banner would always
|
||||
# think the user is behind because pubspec.yaml's static
|
||||
# version drifts from the actual release tag.
|
||||
#
|
||||
# ANDROID_KEYSTORE_PATH is set by the previous step;
|
||||
# build.gradle.kts picks it up and signs the release APK with
|
||||
# the operator's persistent keystore.
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
- name: Upload debug APK artifact
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
@@ -81,14 +122,15 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
# Look up the release ID for this tag (release must exist already;
|
||||
# release.yml creates it as part of the server-side release flow).
|
||||
# Look up the release ID for this tag. The release object must
|
||||
# exist already — operator creates it (or release.yml will, in
|
||||
# a future enhancement) before pushing the tag.
|
||||
RELEASE_ID=$(curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
@@ -96,6 +138,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
|
||||
|
||||
@@ -50,9 +50,11 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main"
|
||||
fi
|
||||
|
||||
@@ -60,9 +62,40 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
# In-app update flow (#397): on tag pushes only, fetch the APK
|
||||
# that flutter.yml is attaching to this same release. flutter.yml
|
||||
# runs in parallel; poll up to 15 min for the asset to appear.
|
||||
# On main pushes (or if the APK never lands), client/ stays empty
|
||||
# and the endpoints return 404 — graceful degradation.
|
||||
- name: Fetch APK for bundled in-app update channel
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
|
||||
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
-o client/minstrel.apk "$APK_URL"; then
|
||||
echo "Got APK on attempt $i"
|
||||
echo "${TAG}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
exit 0
|
||||
fi
|
||||
echo "APK not ready yet (attempt $i/30), sleeping 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel"
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
client/minstrel.apk.version
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
|
||||
+14
-2
@@ -15,7 +15,13 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
@@ -33,9 +39,15 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
||||
# A non-writable path at this location silently breaks every downstream
|
||||
# cache, so we create + chown it once at image build. Operators mount a
|
||||
# named volume on top to persist across container recreates.
|
||||
RUN mkdir -p /app/data && chown -R minstrel:minstrel /app
|
||||
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||
WORKDIR /app
|
||||
|
||||
# In-app update channel (#397). client/ in the build context holds
|
||||
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
||||
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
||||
# APK files aren't present, so non-tag images degrade gracefully.
|
||||
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||
|
||||
USER minstrel
|
||||
EXPOSE 4533
|
||||
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
||||
|
||||
@@ -21,7 +21,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
# compose.yaml
|
||||
services:
|
||||
minstrel:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:v1.0.0
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
- ./music:/music:ro
|
||||
@@ -48,7 +48,7 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings.
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -59,7 +59,6 @@ Most operators only need the env vars in the quickstart above. A few extras wort
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start.
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Bundled client assets
|
||||
|
||||
Holds the Android APK (`minstrel.apk` + `minstrel.apk.version`) that the
|
||||
server serves via `/api/client/version` and `/api/client/apk` for the
|
||||
in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Forgejo release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
|
||||
## Development
|
||||
|
||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
||||
update banner stays hidden ("no update channel" graceful degradation).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
file into this directory:
|
||||
|
||||
```sh
|
||||
cp /path/to/app-release.apk client/minstrel.apk
|
||||
echo "v0.2.0" > client/minstrel.apk.version
|
||||
```
|
||||
|
||||
## Why a directory and not embed.FS?
|
||||
|
||||
The APK is 30-60 MB. Embedding bloats the Go binary and slows
|
||||
`go build` for everyone, even when the APK isn't being changed.
|
||||
File-on-disk also lets operators override at runtime via volume
|
||||
mount on `/app/client/` if they want to ship their own build.
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -67,10 +66,6 @@ func run() error {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil {
|
||||
return fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
|
||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
||||
|
||||
contact := cfg.Library.ContactEmail
|
||||
|
||||
@@ -22,16 +22,6 @@ log:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "text" # text | json
|
||||
|
||||
auth:
|
||||
# One-shot admin bootstrap. Applied only when the users table is empty.
|
||||
# Username defaults to "admin" when unset — you can omit this section
|
||||
# entirely for a working bootstrap. Leave password blank to have the server
|
||||
# generate one and log it to stderr on first start.
|
||||
# Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD
|
||||
admin_bootstrap:
|
||||
username: "admin"
|
||||
password: ""
|
||||
|
||||
library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
|
||||
@@ -39,9 +39,6 @@ services:
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
||||
MINSTREL_STORAGE_DATA_DIR: /app/data
|
||||
# MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap
|
||||
# generates one and prints it to stderr on first boot. Watch the logs
|
||||
# of the minstrel service the first time you bring the stack up.
|
||||
networks:
|
||||
- minstrel
|
||||
ports:
|
||||
|
||||
@@ -43,3 +43,7 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# drift codegen output (regenerated by build_runner; CI runs build_runner)
|
||||
*.g.dart
|
||||
|
||||
|
||||
@@ -30,11 +30,38 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// Real release signing config — populated only when CI exports
|
||||
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
|
||||
// matching password/alias env vars. Without these (local
|
||||
// `flutter build apk --release` runs), the release build falls
|
||||
// through to the debug keystore so local builds still work.
|
||||
//
|
||||
// Why this matters: Flutter's default `flutter run --release` and
|
||||
// CI's earlier setup both signed with the per-machine debug
|
||||
// keystore (~/.android/debug.keystore). Every CI runner generated
|
||||
// its own debug key, so consecutive release APKs had different
|
||||
// signatures and Android refused to upgrade an existing install.
|
||||
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath)
|
||||
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Local-only fallback so `flutter run --release` works
|
||||
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Used in all build flavours (debug / profile / release). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<!-- Android 13+ requires this to be declared AND prompted at runtime
|
||||
for the media notification (now-playing tile) to appear. Audio
|
||||
playback works without it; only the system-tray surface breaks. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app update flow (#397). First update attempt prompts the
|
||||
user to enable "Install unknown apps" for Minstrel in
|
||||
Settings → Apps → Special access. One-time grant; persists. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<application
|
||||
android:label="minstrel"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -49,6 +60,20 @@
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- FileProvider for the in-app update flow (#397). Maps the
|
||||
cache directory (where dio.download writes the APK) to a
|
||||
content:// URI so the PackageInstaller intent can read it
|
||||
across the process boundary. file:// URIs are blocked
|
||||
since Android 7. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
+67
-2
@@ -1,5 +1,70 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// Must extend AudioServiceActivity (not FlutterActivity) so the
|
||||
// audio_service plugin can wire its FlutterEngine through. Without this
|
||||
// the platform call from AudioService.init() throws PlatformException
|
||||
// "The Activity class declared in your AndroidManifest.xml is wrong",
|
||||
// which on first start cascades into a flutter_cache_manager sqlite
|
||||
// EXCLUSIVE-lock crash because the engine partially re-initialises.
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
|
||||
companion object {
|
||||
private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// In-app update channel (#397). Dart calls install(path) with
|
||||
// the cache-dir APK path; we hand it to Android's
|
||||
// PackageInstaller via FileProvider + ACTION_VIEW.
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"install" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
result.error("missing_path", "path argument required", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
installApk(path)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("install_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
throw IllegalStateException("apk not found at $path")
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
// NEW_TASK: PackageInstaller runs in its own task.
|
||||
// GRANT_READ_URI_PERMISSION: hands the content:// URI to
|
||||
// the installer process, which lacks our app's read perms
|
||||
// by default.
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
FileProvider mappings for the in-app update flow (#397).
|
||||
- cache-path/. exposes the app's getCacheDir() so the downloaded
|
||||
APK at <cache>/minstrel-update.apk can be read by the
|
||||
PackageInstaller via the content:// URI built in MainActivity.kt.
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="updates" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Permits cleartext HTTP to 127.0.0.1 ONLY. Required because just_audio +
|
||||
audio_service spin up a local HTTP proxy on loopback to inject the
|
||||
Authorization header (Android's MediaSession API can't pass headers down
|
||||
to ExoPlayer directly). Without this exemption, ExoPlayer hits Android's
|
||||
cleartext block when connecting to the local proxy and audio playback
|
||||
fails with "Cleartext HTTP traffic to 127.0.0.1 not permitted".
|
||||
|
||||
All other hosts inherit cleartextTrafficPermitted="false" — actual
|
||||
remote traffic (to your Minstrel server) must still be HTTPS.
|
||||
|
||||
Loopback traffic doesn't leave the device, so this exemption doesn't
|
||||
reduce security for real network endpoints.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_section_card.dart';
|
||||
|
||||
class AdminLandingScreen extends ConsumerWidget {
|
||||
const AdminLandingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final counts = ref.watch(adminCountsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: counts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (c) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(adminCountsProvider.future),
|
||||
child: ListView(
|
||||
children: [
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_requests'),
|
||||
icon: Icons.inbox,
|
||||
title: 'Requests',
|
||||
subtitle: 'Approve or reject Lidarr requests',
|
||||
count: c.requests,
|
||||
onTap: () => context.push('/admin/requests'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_quarantine'),
|
||||
icon: Icons.warning_amber,
|
||||
title: 'Quarantine',
|
||||
subtitle: 'Resolve scan failures',
|
||||
count: c.quarantine,
|
||||
onTap: () => context.push('/admin/quarantine'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_users'),
|
||||
icon: Icons.people,
|
||||
title: 'Users',
|
||||
subtitle: 'Manage accounts and invites',
|
||||
count: c.users,
|
||||
onTap: () => context.push('/admin/users'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/admin_invites.dart';
|
||||
import '../api/endpoints/admin_quarantine.dart';
|
||||
import '../api/endpoints/admin_requests.dart';
|
||||
import '../api/endpoints/admin_users.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/admin_quarantine_item.dart';
|
||||
import '../models/admin_request.dart';
|
||||
import '../models/admin_user.dart';
|
||||
import '../models/invite.dart';
|
||||
|
||||
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
|
||||
return AdminRequestsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminQuarantineApiProvider =
|
||||
FutureProvider<AdminQuarantineApi>((ref) async {
|
||||
return AdminQuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
|
||||
return AdminUsersApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
|
||||
return AdminInvitesApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class AdminCounts {
|
||||
const AdminCounts({
|
||||
required this.requests,
|
||||
required this.quarantine,
|
||||
required this.users,
|
||||
});
|
||||
final int requests;
|
||||
final int quarantine;
|
||||
final int users;
|
||||
}
|
||||
|
||||
/// Fans out to the three list endpoints in parallel and rolls them up
|
||||
/// into `{requests, quarantine, users}` for the landing screen badges.
|
||||
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
|
||||
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
|
||||
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
|
||||
final usersApi = await ref.watch(adminUsersApiProvider.future);
|
||||
final results = await Future.wait([
|
||||
requestsApi.list(),
|
||||
quarantineApi.list(),
|
||||
usersApi.list(),
|
||||
]);
|
||||
return AdminCounts(
|
||||
requests: (results[0] as List).length,
|
||||
quarantine: (results[1] as List).length,
|
||||
users: (results[2] as List).length,
|
||||
);
|
||||
});
|
||||
|
||||
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async {
|
||||
final api = await ref.watch(adminRequestsApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _decide(id, (api) => api.approve(id));
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _decide(id, (api) => api.reject(id));
|
||||
}
|
||||
|
||||
Future<void> _decide(
|
||||
String id,
|
||||
Future<void> Function(AdminRequestsApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminRequestsApiProvider.future);
|
||||
final current = state.value ?? const <AdminRequest>[];
|
||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||
try {
|
||||
await action(api);
|
||||
// Refresh landing badge.
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminRequestsProvider =
|
||||
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
|
||||
AdminRequestsController.new);
|
||||
|
||||
/// Read side originally defined for the Requests screen's
|
||||
/// requester-UUID → username join. Mutation methods (setAdmin,
|
||||
/// setAutoApprove, resetPassword, delete) added alongside the Users
|
||||
/// screen in Slice 3. resetPassword is admin-supplies; the server
|
||||
/// has no auto-generation mode.
|
||||
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
|
||||
@override
|
||||
Future<List<AdminUser>> build() async {
|
||||
final api = await ref.watch(adminUsersApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Optimistic flip of `is_admin` on the user row; rolls back on
|
||||
/// server error (including the last-admin guard 4xx).
|
||||
Future<void> setAdmin(String id, bool isAdmin) =>
|
||||
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
|
||||
(api) => api.setAdmin(id, isAdmin));
|
||||
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) =>
|
||||
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
|
||||
(api) => api.setAutoApprove(id, autoApprove));
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData(current.where((u) => u.id != id).toList());
|
||||
try {
|
||||
await api.delete(id);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
await api.resetPassword(id, newPassword);
|
||||
}
|
||||
|
||||
Future<void> _patch(
|
||||
String id,
|
||||
AdminUser Function(AdminUser) mutate,
|
||||
Future<void> Function(AdminUsersApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData([
|
||||
for (final u in current) u.id == id ? mutate(u) : u,
|
||||
]);
|
||||
try {
|
||||
await action(api);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
|
||||
AdminUser(
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isAdmin: isAdmin ?? u.isAdmin,
|
||||
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
|
||||
createdAt: u.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
final adminUsersProvider =
|
||||
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
|
||||
AdminUsersController.new);
|
||||
|
||||
class AdminQuarantineController
|
||||
extends AsyncNotifier<List<AdminQuarantineItem>> {
|
||||
@override
|
||||
Future<List<AdminQuarantineItem>> build() async {
|
||||
final api = await ref.watch(adminQuarantineApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) =>
|
||||
_act(trackId, (api) => api.resolve(trackId));
|
||||
Future<void> deleteFile(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteFile(trackId));
|
||||
Future<void> deleteViaLidarr(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteViaLidarr(trackId));
|
||||
|
||||
Future<void> _act(
|
||||
String trackId,
|
||||
Future<void> Function(AdminQuarantineApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminQuarantineApiProvider.future);
|
||||
final current = state.value ?? const <AdminQuarantineItem>[];
|
||||
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
|
||||
try {
|
||||
await action(api);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
|
||||
List<AdminQuarantineItem>>(AdminQuarantineController.new);
|
||||
|
||||
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
|
||||
@override
|
||||
Future<List<Invite>> build() async {
|
||||
final api = await ref.watch(adminInvitesApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Returns the freshly-minted invite so the screen can show the
|
||||
/// token in a copy-once dialog. The new invite is also prepended
|
||||
/// to the local list so it shows up without a refresh.
|
||||
Future<Invite> create({String? note}) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final invite = await api.create(note: note);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData([invite, ...current]);
|
||||
return invite;
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData(current.where((i) => i.token != token).toList());
|
||||
try {
|
||||
await api.revoke(token);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminInvitesProvider =
|
||||
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
|
||||
AdminInvitesController.new);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_quarantine_row.dart';
|
||||
|
||||
class AdminQuarantineScreen extends ConsumerWidget {
|
||||
const AdminQuarantineScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final items = ref.watch(adminQuarantineProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Quarantine', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: items.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No quarantined tracks.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminQuarantineProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminQuarantineProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) => AdminQuarantineRow(
|
||||
key: Key('admin_quarantine_row_${rows[i].trackId}'),
|
||||
item: rows[i],
|
||||
onResolve: () => notifier.resolve(rows[i].trackId),
|
||||
onDeleteFile: () => notifier.deleteFile(rows[i].trackId),
|
||||
onDeleteViaLidarr: () =>
|
||||
notifier.deleteViaLidarr(rows[i].trackId),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/admin_user.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_request_row.dart';
|
||||
|
||||
class AdminRequestsScreen extends ConsumerWidget {
|
||||
const AdminRequestsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final requests = ref.watch(adminRequestsProvider);
|
||||
// Best-effort lookup for requester usernames. If the users provider
|
||||
// hasn't loaded yet, valueOrNull is null and rows fall back to the
|
||||
// UUID prefix; no blocking spinner.
|
||||
final users = ref.watch(adminUsersProvider).value ?? const <AdminUser>[];
|
||||
final usersById = {for (final u in users) u.id: u};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Requests', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/requests')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No pending requests.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminRequestsProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminRequestsProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) {
|
||||
final req = rows[i];
|
||||
final user = usersById[req.userId];
|
||||
final display = user?.username ??
|
||||
(req.userId.length >= 8
|
||||
? req.userId.substring(0, 8)
|
||||
: req.userId);
|
||||
return AdminRequestRow(
|
||||
key: Key('admin_request_row_${req.id}'),
|
||||
request: req,
|
||||
requesterDisplay: display,
|
||||
onApprove: () => notifier.approve(req.id),
|
||||
onReject: () => notifier.reject(req.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_user_edit_sheet.dart';
|
||||
import 'widgets/admin_user_row.dart';
|
||||
import 'widgets/invite_row.dart';
|
||||
|
||||
class AdminUsersScreen extends ConsumerWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.watch(adminUsersProvider);
|
||||
final invites = ref.watch(adminInvitesProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Users', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/users')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(adminUsersProvider);
|
||||
ref.invalidate(adminInvitesProvider);
|
||||
},
|
||||
child: ListView(
|
||||
children: [
|
||||
const _SectionHeader(text: 'Users'),
|
||||
users.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (rows) => Column(
|
||||
children: rows
|
||||
.map((u) => AdminUserRow(
|
||||
key: Key('admin_user_row_${u.id}'),
|
||||
user: u,
|
||||
onTap: () => AdminUserEditSheet.show(context, u),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(
|
||||
text: 'Invites',
|
||||
trailing: TextButton.icon(
|
||||
key: const Key('invite_generate_button'),
|
||||
onPressed: () => _showGenerateInvite(context, ref),
|
||||
icon: Icon(Icons.add, color: fs.parchment),
|
||||
label: Text('Generate',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
),
|
||||
invites.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (rows) => Column(
|
||||
children: rows
|
||||
.map((i) => InviteRow(
|
||||
key: Key('invite_row_${i.token}'),
|
||||
invite: i,
|
||||
onRevoke: () => ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.revoke(i.token),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
final note = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Generate invite'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Token expires in 24 hours.'),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note (optional)',
|
||||
helperText: 'e.g. "for alice"',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('Generate'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (note == null || !context.mounted) return;
|
||||
try {
|
||||
final invite = await ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.create(note: note.isEmpty ? null : note);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Invite created'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Share this token with the new user:'),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
invite.token,
|
||||
style: const TextStyle(fontFamily: 'JetBrainsMono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: invite.token));
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, this.trailing});
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminQuarantineRow extends StatelessWidget {
|
||||
const AdminQuarantineRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onResolve,
|
||||
required this.onDeleteFile,
|
||||
required this.onDeleteViaLidarr,
|
||||
});
|
||||
|
||||
final AdminQuarantineItem item;
|
||||
final VoidCallback onResolve;
|
||||
final VoidCallback onDeleteFile;
|
||||
final VoidCallback onDeleteViaLidarr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ExpansionTile(
|
||||
key: Key('admin_quarantine_tile_${item.trackId}'),
|
||||
title: Text(
|
||||
item.trackTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${item.artistName} · ${item.albumTitle}',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
Text(
|
||||
'${item.reportCount} '
|
||||
'${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}',
|
||||
style: TextStyle(color: fs.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: Key('admin_quarantine_menu_${item.trackId}'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case 'resolve':
|
||||
onResolve();
|
||||
break;
|
||||
case 'delete_file':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete file?',
|
||||
message:
|
||||
'Permanently delete the local file for "${item.trackTitle}". '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
onDeleteFile();
|
||||
}
|
||||
break;
|
||||
case 'delete_lidarr':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete via Lidarr?',
|
||||
message:
|
||||
'Ask Lidarr to delete the file for "${item.trackTitle}".',
|
||||
)) {
|
||||
onDeleteViaLidarr();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: 'resolve', child: Text('Resolve')),
|
||||
PopupMenuItem(value: 'delete_file', child: Text('Delete file')),
|
||||
PopupMenuItem(
|
||||
value: 'delete_lidarr', child: Text('Delete via Lidarr')),
|
||||
],
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
children: [
|
||||
for (final report in item.reports)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'${report.username} — ${report.reason}',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
subtitle: report.notes != null
|
||||
? Text(report.notes!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminRequestRow extends StatelessWidget {
|
||||
const AdminRequestRow({
|
||||
super.key,
|
||||
required this.request,
|
||||
required this.requesterDisplay,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
final AdminRequest request;
|
||||
|
||||
/// Pre-resolved username (or UUID-prefix fallback) for the requester.
|
||||
final String requesterDisplay;
|
||||
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final subtitle =
|
||||
'${request.kind} · ${request.artistName} · requested by $requesterDisplay';
|
||||
return ListTile(
|
||||
title: Text(
|
||||
request.displayName,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: onApprove,
|
||||
style: TextButton.styleFrom(foregroundColor: fs.moss),
|
||||
child: const Text('Approve'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reject request?'),
|
||||
content: Text('Reject "${request.displayName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) onReject();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminSectionCard extends StatelessWidget {
|
||||
const AdminSectionCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.count,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Card(
|
||||
color: fs.iron,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: fs.parchment),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.bronze,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../admin_providers.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminUserEditSheet extends ConsumerWidget {
|
||||
const AdminUserEditSheet({super.key, required this.user});
|
||||
|
||||
final AdminUser user;
|
||||
|
||||
static Future<void> show(BuildContext context, AdminUser user) =>
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => AdminUserEditSheet(user: user),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.read(adminUsersProvider.notifier);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_is_admin'),
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
value: user.isAdmin,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAdmin(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_auto_approve'),
|
||||
title: Text('Auto-approve requests',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
value: user.autoApproveRequests,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAutoApprove(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('user_edit_reset_password'),
|
||||
onPressed: () => _resetPassword(context, users),
|
||||
child: const Text('Reset password'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
key: const Key('user_edit_delete'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
onPressed: () => _deleteUser(context, users),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPassword(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
final controller = TextEditingController();
|
||||
final newPw = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reset password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
helperText: 'Minimum 8 characters',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (controller.text.length >= 8) {
|
||||
Navigator.pop(ctx, controller.text);
|
||||
}
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (newPw == null || !context.mounted) return;
|
||||
try {
|
||||
await users.resetPassword(user.id, newPw);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Password reset.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteUser(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
if (!await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete user?',
|
||||
message:
|
||||
'Permanently delete user "${user.username}" and all their data. '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.delete(user.id);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminUserRow extends StatelessWidget {
|
||||
const AdminUserRow({super.key, required this.user, required this.onTap});
|
||||
|
||||
final AdminUser user;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze),
|
||||
if (user.autoApproveRequests)
|
||||
_Badge(label: 'auto-approve', color: fs.moss),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class InviteRow extends StatelessWidget {
|
||||
const InviteRow({
|
||||
super.key,
|
||||
required this.invite,
|
||||
required this.onRevoke,
|
||||
});
|
||||
|
||||
final Invite invite;
|
||||
final VoidCallback onRevoke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
invite.token,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: fs.ash, size: 18),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: invite.token)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text('expires ${invite.expiresAt}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.note != null)
|
||||
Text('· ${invite.note}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.isRedeemed)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.ash,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'redeemed',
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: fs.oxblood),
|
||||
tooltip: 'Revoke',
|
||||
onPressed: onRevoke,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Modal bottom sheet that requires the user to type [confirmWord]
|
||||
/// (default "DELETE") before the confirm button enables. Used for
|
||||
/// destructive actions like quarantine delete-file / delete-via-Lidarr
|
||||
/// and user delete.
|
||||
///
|
||||
/// Returns true if the user confirmed, false (or null → false) otherwise.
|
||||
class TypedConfirmSheet extends StatefulWidget {
|
||||
const TypedConfirmSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.confirmWord = 'DELETE',
|
||||
this.confirmLabel = 'Delete',
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmWord;
|
||||
final String confirmLabel;
|
||||
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmWord = 'DELETE',
|
||||
String confirmLabel = 'Delete',
|
||||
}) async {
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => TypedConfirmSheet(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmWord: confirmWord,
|
||||
confirmLabel: confirmLabel,
|
||||
),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
|
||||
}
|
||||
|
||||
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
|
||||
final _controller = TextEditingController();
|
||||
bool _enabled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(widget.message, style: TextStyle(color: fs.ash)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Type ${widget.confirmWord} to confirm:',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('typed_confirm_input'),
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
onChanged: (v) =>
|
||||
setState(() => _enabled = v == widget.confirmWord),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
key: const Key('typed_confirm_button'),
|
||||
onPressed:
|
||||
_enabled ? () => Navigator.pop(context, true) : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: fs.oxblood,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
|
||||
class AdminInvitesApi {
|
||||
AdminInvitesApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
|
||||
Future<List<Invite>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
|
||||
final raw = (r.data?['invites'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
|
||||
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
|
||||
/// Returns the bare invite (not enveloped).
|
||||
Future<Invite> create({String? note}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/admin/invites',
|
||||
data: {if (note != null && note.isNotEmpty) 'note': note},
|
||||
);
|
||||
return Invite.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
await _dio.delete<void>('/api/admin/invites/$token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
|
||||
class AdminQuarantineApi {
|
||||
AdminQuarantineApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
|
||||
/// quarantine rows.
|
||||
Future<List<AdminQuarantineItem>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
|
||||
}
|
||||
|
||||
Future<void> deleteViaLidarr(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
class AdminRequestsApi {
|
||||
AdminRequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/requests — flat list of pending requests.
|
||||
/// Server defaults to ?status=pending; we don't override.
|
||||
Future<List<AdminRequest>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/approve');
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/reject');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
|
||||
class AdminUsersApi {
|
||||
AdminUsersApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
|
||||
Future<List<AdminUser>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
|
||||
final raw = (r.data?['users'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> setAdmin(String id, bool isAdmin) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/admin',
|
||||
data: {'is_admin': isAdmin},
|
||||
);
|
||||
}
|
||||
|
||||
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
|
||||
/// — different from the response field name. Verified against
|
||||
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/auto-approve',
|
||||
data: {'auto_approve': autoApprove},
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin supplies the new password; server returns 204. There is no
|
||||
/// server-generated-password mode.
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
await _dio.post<void>(
|
||||
'/api/admin/users/$id/reset-password',
|
||||
data: {'password': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _dio.delete<void>('/api/admin/users/$id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/lidarr.dart';
|
||||
|
||||
class DiscoverApi {
|
||||
DiscoverApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
||||
/// 60s LRU cache for repeat queries so re-typing the same string in
|
||||
/// quick succession is cheap.
|
||||
Future<List<LidarrSearchResult>> search(
|
||||
String query,
|
||||
LidarrRequestKind kind,
|
||||
) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
'/api/lidarr/search',
|
||||
queryParameters: {'q': query, 'kind': kind.wire},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// POST /api/requests. Returns the newly-created LidarrRequest body
|
||||
/// — we don't expose a typed wrapper for that yet on mobile (admin
|
||||
/// reviews requests; user just kicks them off), so we discard the
|
||||
/// response and surface success/failure to the caller.
|
||||
Future<void> createRequest({
|
||||
required LidarrRequestKind kind,
|
||||
required String artistMbid,
|
||||
required String artistName,
|
||||
String? albumMbid,
|
||||
String? albumTitle,
|
||||
String? trackMbid,
|
||||
String? trackTitle,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'kind': kind.wire,
|
||||
'lidarr_artist_mbid': artistMbid,
|
||||
'artist_name': artistName,
|
||||
};
|
||||
if (albumMbid != null && albumMbid.isNotEmpty) {
|
||||
body['lidarr_album_mbid'] = albumMbid;
|
||||
}
|
||||
if (trackMbid != null && trackMbid.isNotEmpty) {
|
||||
body['lidarr_track_mbid'] = trackMbid;
|
||||
}
|
||||
if (albumTitle != null && albumTitle.isNotEmpty) {
|
||||
body['album_title'] = albumTitle;
|
||||
}
|
||||
if (trackTitle != null && trackTitle.isNotEmpty) {
|
||||
body['track_title'] = trackTitle;
|
||||
}
|
||||
await _dio.post<Map<String, dynamic>>('/api/requests', data: body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/page.dart';
|
||||
|
||||
/// Paged library browse endpoints. Distinct from LibraryApi (which
|
||||
/// fetches Home + entity detail) so screens that just need a flat
|
||||
/// list don't drag in detail-page providers.
|
||||
class LibraryListsApi {
|
||||
LibraryListsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||
// not /api/library/artists. Albums use /api/library/albums for
|
||||
// historical reasons; the paths aren't symmetric.
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/artists',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'sort': 'alpha',
|
||||
},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/albums',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/page.dart';
|
||||
import '../../models/track.dart';
|
||||
|
||||
enum LikeKind { artist, album, track }
|
||||
|
||||
extension LikeKindPath on LikeKind {
|
||||
@@ -22,6 +27,31 @@ class LikesApi {
|
||||
await _dio.delete<void>('/api/likes/${kind.path}/$id');
|
||||
}
|
||||
|
||||
/// GET /api/likes/tracks?limit=N&offset=N. Paged.
|
||||
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/tracks',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/albums',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||
}
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/likes/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
/// Returns sets of {artists, albums, tracks} the user has liked.
|
||||
/// Server response keys (verified in internal/api/likes.go
|
||||
/// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/history_event.dart';
|
||||
import '../../models/quarantine_mine.dart';
|
||||
import '../../models/system_playlists_status.dart';
|
||||
|
||||
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
||||
class MeApi {
|
||||
MeApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/me/history. Paginated via offset/limit; server emits
|
||||
/// `{events, has_more}` rather than the standard `Page<T>` envelope.
|
||||
Future<HistoryPage> history({int limit = 50, int offset = 0}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/me/history',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return HistoryPage.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// GET /api/quarantine/mine — flat list (no envelope).
|
||||
Future<List<QuarantineMineRow>> quarantineMine() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/quarantine/mine');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/me/system-playlists-status. Returns the caller's most
|
||||
/// recent system-playlist build state. Used by the home Playlists
|
||||
/// row to choose between real and placeholder cards.
|
||||
Future<SystemPlaylistsStatus> systemPlaylistsStatus() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/me/system-playlists-status',
|
||||
);
|
||||
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/playlist.dart';
|
||||
|
||||
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
|
||||
/// the UI can present them as different sections; we preserve that split
|
||||
/// to give the integrations page room to grow.
|
||||
class PlaylistsList {
|
||||
const PlaylistsList({required this.owned, required this.public});
|
||||
final List<Playlist> owned;
|
||||
final List<Playlist> public;
|
||||
|
||||
factory PlaylistsList.empty() =>
|
||||
const PlaylistsList(owned: [], public: []);
|
||||
|
||||
/// Concatenated view for callers that don't care about the split.
|
||||
List<Playlist> get all => [...owned, ...public];
|
||||
}
|
||||
|
||||
class PlaylistsApi {
|
||||
PlaylistsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/playlists?kind=user|system|all. Server returns
|
||||
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
|
||||
/// playlists, filtered by the kind param (default "user"). Public
|
||||
/// is other users' shared playlists; not filtered by kind.
|
||||
Future<PlaylistsList> list({String kind = 'user'}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/playlists',
|
||||
queryParameters: {'kind': kind},
|
||||
);
|
||||
final body = r.data ?? const <String, dynamic>{};
|
||||
List<Playlist> parse(String key) {
|
||||
final raw = (body[key] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
return PlaylistsList(owned: parse('owned'), public: parse('public'));
|
||||
}
|
||||
|
||||
Future<PlaylistDetail> get(String id) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
||||
/// playlist detail with the new rows.
|
||||
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
||||
await _dio.post<void>(
|
||||
'/api/playlists/$playlistId/tracks',
|
||||
data: {'track_ids': trackIds},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// /api/quarantine — flag a track (with reason + optional notes) and
|
||||
/// unflag it. Both endpoints are user-scoped: callers can only flag
|
||||
/// their own quarantine entries; admins use a separate /admin/quarantine
|
||||
/// surface.
|
||||
class QuarantineApi {
|
||||
QuarantineApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// POST /api/quarantine. Server returns 201 with the row.
|
||||
/// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other.
|
||||
Future<void> flag(String trackId, String reason, {String notes = ''}) async {
|
||||
await _dio.post<void>('/api/quarantine', data: {
|
||||
'track_id': trackId,
|
||||
'reason': reason,
|
||||
'notes': notes,
|
||||
});
|
||||
}
|
||||
|
||||
/// DELETE /api/quarantine/{track_id}. Server returns 204.
|
||||
Future<void> unflag(String trackId) async {
|
||||
await _dio.delete<void>('/api/quarantine/$trackId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
|
||||
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
|
||||
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
|
||||
/// scored by the server's recommendation engine. The shape matches
|
||||
/// what `playerActions.playTracks` expects, so a radio start is just
|
||||
/// one fetch + one playTracks call.
|
||||
class RadioApi {
|
||||
RadioApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/radio',
|
||||
queryParameters: {
|
||||
'seed_track': trackId,
|
||||
if (limit != null) 'limit': limit,
|
||||
},
|
||||
);
|
||||
final raw = (r.data?['tracks'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
/// User-side requests API — `/api/requests`. Server scopes results to
|
||||
/// the caller; admins see only their own here, not all users'. The
|
||||
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
|
||||
///
|
||||
/// Wire shape is identical to the admin endpoint, so the same
|
||||
/// AdminRequest model is reused.
|
||||
class RequestsApi {
|
||||
RequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/requests — caller's own requests, all statuses.
|
||||
Future<List<AdminRequest>> listMine() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// DELETE /api/requests/{id} — cancel a pending request. Server
|
||||
/// returns the cancelled row body (not 204) so the caller can patch
|
||||
/// local state without a refetch.
|
||||
Future<AdminRequest> cancel(String id) async {
|
||||
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
|
||||
return AdminRequest.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/search_response.dart';
|
||||
|
||||
/// SearchApi wraps GET /api/search. The server runs three facets (artists,
|
||||
/// albums, tracks) sharing one limit/offset pair. Each facet returns its
|
||||
/// own total reflecting the full match count.
|
||||
class SearchApi {
|
||||
SearchApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// Empty / whitespace-only query is the caller's responsibility to
|
||||
/// guard against — the server returns 400 bad_request for it.
|
||||
Future<SearchResponse> search(
|
||||
String query, {
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/search',
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
return SearchResponse.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/my_profile.dart';
|
||||
|
||||
class SettingsApi {
|
||||
SettingsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<MyProfile> getProfile() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/me');
|
||||
return MyProfile.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// Pass only the fields you want to change; server merges. Empty
|
||||
/// strings clear; null leaves the existing value alone.
|
||||
Future<MyProfile> updateProfile({String? displayName, String? email}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (displayName != null) body['display_name'] = displayName;
|
||||
if (email != null) body['email'] = email;
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/profile',
|
||||
data: body,
|
||||
);
|
||||
return MyProfile.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String current,
|
||||
required String next,
|
||||
}) async {
|
||||
await _dio.put<void>('/api/me/password', data: {
|
||||
'current_password': current,
|
||||
'new_password': next,
|
||||
});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> getListenBrainz() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/me/listenbrainz');
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> setListenBrainzToken(String token) async {
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/listenbrainz',
|
||||
data: {'token': token},
|
||||
);
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<ListenBrainzStatus> setListenBrainzEnabled(bool enabled) async {
|
||||
final r = await _dio.put<Map<String, dynamic>>(
|
||||
'/api/me/listenbrainz',
|
||||
data: {'enabled': enabled},
|
||||
);
|
||||
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'cache/metadata_prefetcher.dart';
|
||||
import 'cache/prefetcher.dart';
|
||||
import 'cache/sync_controller.dart';
|
||||
import 'shared/routing.dart';
|
||||
import 'theme/theme_data.dart';
|
||||
import 'theme/theme_mode_provider.dart';
|
||||
|
||||
class MinstrelApp extends ConsumerWidget {
|
||||
class MinstrelApp extends ConsumerStatefulWidget {
|
||||
const MinstrelApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
|
||||
}
|
||||
|
||||
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Activate offline-mode infrastructure once the first frame ships.
|
||||
// SyncController.sync() is connectivity-aware (no-ops on no auth /
|
||||
// no server URL), so calling it unconditionally is safe.
|
||||
// Reading prefetcherProvider runs its constructor, which wires the
|
||||
// queue + settings listeners.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// ignore: unawaited_futures
|
||||
ref.read(syncControllerProvider.notifier).sync();
|
||||
ref.read(prefetcherProvider);
|
||||
// Metadata prefetcher: when /api/home returns, fire background
|
||||
// albumProvider/artistProvider reads for the top-N items in
|
||||
// each section so subsequent taps are drift hits, not network
|
||||
// round trips.
|
||||
ref.read(metadataPrefetcherProvider);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||
return MaterialApp.router(
|
||||
title: 'Minstrel',
|
||||
theme: buildThemeData(),
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
themeMode: mode.materialMode,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
// Drift row → model adapters and reverse-write adapters for populating
|
||||
// drift from REST responses (#357 plan C).
|
||||
//
|
||||
// The cache loses some server-derived fields:
|
||||
// - ArtistRef.coverUrl (server computes from most-recent album)
|
||||
// - AlbumRef.coverUrl (server-emitted derived path)
|
||||
// - TrackRef.streamUrl (could be reconstructed but kept empty for clarity)
|
||||
// UI already handles empty coverUrl/streamUrl gracefully. REST cold-cache
|
||||
// fallback briefly shows the real values before drift takes over.
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import 'db.dart';
|
||||
|
||||
extension CachedArtistAdapter on CachedArtist {
|
||||
ArtistRef toRef() => ArtistRef(
|
||||
id: id,
|
||||
name: name,
|
||||
sortName: sortName,
|
||||
);
|
||||
}
|
||||
|
||||
extension ArtistRefDriftWrite on ArtistRef {
|
||||
CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert(
|
||||
id: id,
|
||||
name: name,
|
||||
sortName: sortName.isNotEmpty ? sortName : name,
|
||||
);
|
||||
}
|
||||
|
||||
extension CachedAlbumAdapter on CachedAlbum {
|
||||
/// `artistName` is supplied by the joined CachedArtists row at query time.
|
||||
/// `coverUrl` is reconstructed deterministically from the album id —
|
||||
/// the server emits the same shape (see internal/api/convert.go:69).
|
||||
/// We don't need to persist it, so AlbumRef.coverUrl is non-empty
|
||||
/// even when the row was populated from a sync that didn't carry the
|
||||
/// derived URL.
|
||||
AlbumRef toRef({String artistName = ''}) => AlbumRef(
|
||||
id: id,
|
||||
title: title,
|
||||
sortTitle: sortTitle,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
coverUrl: '/api/albums/$id/cover',
|
||||
);
|
||||
}
|
||||
|
||||
extension AlbumRefDriftWrite on AlbumRef {
|
||||
CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert(
|
||||
id: id,
|
||||
artistId: artistId,
|
||||
title: title,
|
||||
sortTitle: sortTitle.isNotEmpty ? sortTitle : title,
|
||||
);
|
||||
}
|
||||
|
||||
extension CachedTrackAdapter on CachedTrack {
|
||||
/// `artistName` and `albumTitle` come from joined rows.
|
||||
TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef(
|
||||
id: id,
|
||||
title: title,
|
||||
albumId: albumId,
|
||||
albumTitle: albumTitle,
|
||||
artistId: artistId,
|
||||
artistName: artistName,
|
||||
trackNumber: trackNumber,
|
||||
discNumber: discNumber,
|
||||
durationSec: durationMs ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
extension TrackRefDriftWrite on TrackRef {
|
||||
CachedTracksCompanion toDrift() => CachedTracksCompanion.insert(
|
||||
id: id,
|
||||
albumId: albumId,
|
||||
artistId: artistId,
|
||||
title: title,
|
||||
durationMs: drift.Value(durationSec * 1000),
|
||||
trackNumber: drift.Value(trackNumber),
|
||||
discNumber: drift.Value(discNumber),
|
||||
);
|
||||
}
|
||||
|
||||
extension CachedPlaylistAdapter on CachedPlaylist {
|
||||
/// `ownerUsername` is server-derived; cache stores the userId only.
|
||||
/// Pass empty unless a join supplies it.
|
||||
/// `coverUrl` is reconstructed deterministically from the playlist
|
||||
/// id — the server serves the cached collage at this path
|
||||
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
|
||||
/// the server hasn't built a collage yet (system playlists with no
|
||||
/// tracks at build time), the endpoint 404s and PlaylistCard's
|
||||
/// ServerImage falls back to its slate placeholder.
|
||||
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
||||
id: id,
|
||||
userId: userId,
|
||||
name: name,
|
||||
description: description,
|
||||
isPublic: isPublic,
|
||||
systemVariant: systemVariant,
|
||||
trackCount: trackCount,
|
||||
coverUrl: '/api/playlists/$id/cover',
|
||||
ownerUsername: ownerUsername,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
);
|
||||
}
|
||||
|
||||
extension PlaylistDriftWrite on Playlist {
|
||||
CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert(
|
||||
id: id,
|
||||
userId: userId,
|
||||
name: name,
|
||||
description: drift.Value(description),
|
||||
isPublic: drift.Value(isPublic),
|
||||
trackCount: drift.Value(trackCount),
|
||||
systemVariant: drift.Value(systemVariant),
|
||||
);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import 'db.dart';
|
||||
|
||||
/// Owns the audio cache directory + drift index.
|
||||
/// API:
|
||||
/// - isCached(trackId)
|
||||
/// - pathFor(trackId)
|
||||
/// - pin(trackId, source) — downloads + indexes
|
||||
/// - unpin(trackId)
|
||||
/// - evict(targetBytes) — tiered LRU
|
||||
/// - usageBytes()
|
||||
/// - clearAll()
|
||||
class AudioCacheManager {
|
||||
AudioCacheManager({
|
||||
required AppDb db,
|
||||
required Future<Dio> Function() dioFactory,
|
||||
Future<Directory> Function()? cacheDirFactory,
|
||||
}) : _db = db,
|
||||
_dioFactory = dioFactory,
|
||||
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
|
||||
|
||||
final AppDb _db;
|
||||
final Future<Dio> Function() _dioFactory;
|
||||
final Future<Directory> Function() _cacheDirFactory;
|
||||
|
||||
Future<String> _tracksDir() async {
|
||||
final base = await _cacheDirFactory();
|
||||
final d = Directory('${base.path}/audio_cache');
|
||||
if (!await d.exists()) await d.create(recursive: true);
|
||||
return d.path;
|
||||
}
|
||||
|
||||
/// True if the trackId has a complete file on disk.
|
||||
Future<bool> isCached(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return false;
|
||||
return File(row.path).existsSync();
|
||||
}
|
||||
|
||||
/// Returns the local file path if cached, else null.
|
||||
Future<String?> pathFor(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
return File(row.path).existsSync() ? row.path : null;
|
||||
}
|
||||
|
||||
/// Downloads the track's stream to disk and indexes it. Idempotent —
|
||||
/// returns the existing path if already cached.
|
||||
Future<String?> pin(String trackId, {required CacheSource source}) async {
|
||||
final existing = await pathFor(trackId);
|
||||
if (existing != null) return existing;
|
||||
|
||||
final dir = await _tracksDir();
|
||||
final path = '$dir/$trackId.mp3';
|
||||
final dio = await _dioFactory();
|
||||
try {
|
||||
await dio.download('/api/tracks/$trackId/stream', path);
|
||||
} catch (_) {
|
||||
final f = File(path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
return null;
|
||||
}
|
||||
final size = await File(path).length();
|
||||
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: trackId,
|
||||
path: path,
|
||||
sizeBytes: size,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Registers a file already on disk in the cache index. Intended for
|
||||
/// the streaming path (LockCachingAudioSource) which writes the file
|
||||
/// itself; we need an index row so eviction can find and delete it
|
||||
/// when usage exceeds the cap. `source` defaults to incidental so
|
||||
/// stream-cached tracks are first to be evicted.
|
||||
Future<void> registerStreamCache(
|
||||
String trackId,
|
||||
String path,
|
||||
int sizeBytes, {
|
||||
CacheSource source = CacheSource.incidental,
|
||||
}) async {
|
||||
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: trackId,
|
||||
path: path,
|
||||
sizeBytes: sizeBytes,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a track from the index AND deletes the file.
|
||||
Future<void> unpin(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.getSingleOrNull();
|
||||
if (row == null) return;
|
||||
final f = File(row.path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
await (_db.delete(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.go();
|
||||
}
|
||||
|
||||
/// Total bytes used by the cache. Walks the cache directory directly
|
||||
/// instead of summing the index, because the streaming-as-you-play
|
||||
/// path (audio_handler's LockCachingAudioSource) writes files
|
||||
/// without registering an index row. The index sum would always
|
||||
/// understate (often to zero) for users who only stream.
|
||||
Future<int> usageBytes() async {
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (!await dir.exists()) return 0;
|
||||
var total = 0;
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {
|
||||
// Race against concurrent writes / deletes — just skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
||||
/// incidental → autoPrefetch → autoPlaylist → autoLiked.
|
||||
/// `manual` never evicts via this path; only clearAll() removes them.
|
||||
Future<void> evict({required int targetBytes}) async {
|
||||
final used = await usageBytes();
|
||||
if (used <= targetBytes) return;
|
||||
final order = [
|
||||
CacheSource.incidental,
|
||||
CacheSource.autoPrefetch,
|
||||
CacheSource.autoPlaylist,
|
||||
CacheSource.autoLiked,
|
||||
];
|
||||
int remaining = used - targetBytes;
|
||||
for (final src in order) {
|
||||
if (remaining <= 0) break;
|
||||
final rows = await (_db.select(_db.audioCacheIndex)
|
||||
..where((t) => t.source.equalsValue(src))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||
.get();
|
||||
for (final row in rows) {
|
||||
if (remaining <= 0) break;
|
||||
final f = File(row.path);
|
||||
if (f.existsSync()) await f.delete();
|
||||
await (_db.delete(_db.audioCacheIndex)
|
||||
..where((t) => t.trackId.equals(row.trackId)))
|
||||
.go();
|
||||
remaining -= row.sizeBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
|
||||
/// button — `manual` source rows are removed here but only here.
|
||||
Future<void> clearAll() async {
|
||||
final dir = await _tracksDir();
|
||||
final d = Directory(dir);
|
||||
if (d.existsSync()) {
|
||||
for (final entity in d.listSync()) {
|
||||
if (entity is File) await entity.delete();
|
||||
}
|
||||
}
|
||||
await _db.delete(_db.audioCacheIndex).go();
|
||||
}
|
||||
}
|
||||
|
||||
/// AppDb singleton. One per app run; ref.onDispose closes it.
|
||||
final appDbProvider = Provider<AppDb>((ref) {
|
||||
final db = AppDb();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final audioCacheManagerProvider = Provider<AudioCacheManager>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return AudioCacheManager(
|
||||
db: db,
|
||||
dioFactory: () async => ref.read(dioProvider.future),
|
||||
);
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C).
|
||||
//
|
||||
// Subscribes to a drift watch() stream. On each emission:
|
||||
// - non-empty → map to result type T and yield
|
||||
// - empty + online → fetch via REST, populate drift, await re-emission
|
||||
// - empty + offline → yield mapped empty result (UI shows empty state)
|
||||
//
|
||||
// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh
|
||||
// in the background after the first non-empty emission. Use for
|
||||
// aggregate lists (playlists, etc.) where the server may have rows
|
||||
// the local sync didn't pick up — yields cache immediately, refreshes
|
||||
// silently, and drift watch() re-emits with whatever new rows landed.
|
||||
//
|
||||
// The pattern lets every read provider trust drift as the source of
|
||||
// truth. SyncController keeps drift fresh in the background; widget
|
||||
// rebuilds happen automatically as drift writes propagate via watch().
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
// `tag` parameter is preserved for future ad-hoc instrumentation.
|
||||
// Normal operation only logs failure paths so the per-screen log
|
||||
// noise stays low.
|
||||
|
||||
/// Wraps the watch + cold-cache fallback pattern. Generic over:
|
||||
/// D — the drift row type (or TypedResult for joins)
|
||||
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
||||
///
|
||||
/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()`
|
||||
/// returns true. It must populate drift via its own side-effect; the
|
||||
/// drift watch() stream will re-emit and this helper yields the
|
||||
/// populated rows on the next iteration.
|
||||
///
|
||||
/// REST failures are swallowed — the helper falls through to yielding
|
||||
/// the empty result. Caller is responsible for surfacing errors via
|
||||
/// toast etc.
|
||||
Stream<T> cacheFirst<D, T>({
|
||||
required Stream<List<D>> driftStream,
|
||||
required Future<void> Function() fetchAndPopulate,
|
||||
required T Function(List<D>) toResult,
|
||||
required Future<bool> Function() isOnline,
|
||||
bool alwaysRefresh = false,
|
||||
String? tag,
|
||||
}) async* {
|
||||
// Tracks whether we've already kicked off a stale-while-revalidate
|
||||
// refresh for this stream subscription, so we don't fire one on every
|
||||
// drift re-emission (otherwise the populate cycles forever).
|
||||
var revalidated = false;
|
||||
|
||||
await for (final rows in driftStream) {
|
||||
if (rows.isNotEmpty) {
|
||||
yield toResult(rows);
|
||||
// Stale-while-revalidate: yield cache immediately, then kick off
|
||||
// a REST refresh in the background. Drift watch() picks up the
|
||||
// resulting writes and re-emits via this same stream loop.
|
||||
// Useful for aggregate lists (e.g. playlists) where the server
|
||||
// may have rows the local sync didn't pick up.
|
||||
if (alwaysRefresh && !revalidated && await isOnline()) {
|
||||
revalidated = true;
|
||||
unawaited(_safeFetch(fetchAndPopulate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (await isOnline()) {
|
||||
try {
|
||||
await fetchAndPopulate();
|
||||
// The drift watch() stream re-emits with the populated rows on
|
||||
// the next loop iteration. Don't yield here.
|
||||
} catch (e, st) {
|
||||
if (tag != null) {
|
||||
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||
}
|
||||
yield toResult(rows); // empty result; caller surfaces error
|
||||
}
|
||||
} else {
|
||||
yield toResult(rows); // empty result; offline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _safeFetch(Future<void> Function() fn) async {
|
||||
try {
|
||||
await fn();
|
||||
} catch (_) {
|
||||
// Background revalidate — swallow; UI already showed cached state.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../auth/auth_provider.dart' show secureStorageProvider;
|
||||
|
||||
/// Operator-tunable cache settings (#357 plan B). Persisted via
|
||||
/// flutter_secure_storage on the same device.
|
||||
class CacheSettings {
|
||||
const CacheSettings({
|
||||
required this.capBytes,
|
||||
required this.prefetchWindow,
|
||||
required this.cacheLikedTracks,
|
||||
});
|
||||
|
||||
/// 0 = unlimited.
|
||||
final int capBytes;
|
||||
|
||||
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
|
||||
final int prefetchWindow;
|
||||
|
||||
/// When true, every like_track event triggers a pin with source autoLiked.
|
||||
final bool cacheLikedTracks;
|
||||
|
||||
CacheSettings copyWith({
|
||||
int? capBytes,
|
||||
int? prefetchWindow,
|
||||
bool? cacheLikedTracks,
|
||||
}) =>
|
||||
CacheSettings(
|
||||
capBytes: capBytes ?? this.capBytes,
|
||||
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
|
||||
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
|
||||
);
|
||||
|
||||
static const defaults = CacheSettings(
|
||||
capBytes: 5 * 1024 * 1024 * 1024,
|
||||
prefetchWindow: 5,
|
||||
cacheLikedTracks: true,
|
||||
);
|
||||
}
|
||||
|
||||
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||
static const _kCap = 'cache_cap_bytes';
|
||||
static const _kPrefetch = 'cache_prefetch_window';
|
||||
static const _kCacheLiked = 'cache_liked_tracks';
|
||||
|
||||
late FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<CacheSettings> build() async {
|
||||
_storage = ref.read(secureStorageProvider);
|
||||
final cap = await _storage.read(key: _kCap);
|
||||
final pre = await _storage.read(key: _kPrefetch);
|
||||
final liked = await _storage.read(key: _kCacheLiked);
|
||||
return CacheSettings(
|
||||
capBytes: cap == null
|
||||
? CacheSettings.defaults.capBytes
|
||||
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
|
||||
prefetchWindow: pre == null
|
||||
? CacheSettings.defaults.prefetchWindow
|
||||
: (int.tryParse(pre) ?? 5).clamp(1, 10),
|
||||
cacheLikedTracks: liked == null
|
||||
? CacheSettings.defaults.cacheLikedTracks
|
||||
: liked == 'true',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setCapBytes(int bytes) async {
|
||||
await _storage.write(key: _kCap, value: bytes.toString());
|
||||
state = AsyncData(state.value!.copyWith(capBytes: bytes));
|
||||
}
|
||||
|
||||
Future<void> setPrefetchWindow(int n) async {
|
||||
final clamped = n.clamp(1, 10);
|
||||
await _storage.write(key: _kPrefetch, value: clamped.toString());
|
||||
state = AsyncData(state.value!.copyWith(prefetchWindow: clamped));
|
||||
}
|
||||
|
||||
Future<void> setCacheLikedTracks(bool on) async {
|
||||
await _storage.write(key: _kCacheLiked, value: on.toString());
|
||||
state = AsyncData(state.value!.copyWith(cacheLikedTracks: on));
|
||||
}
|
||||
}
|
||||
|
||||
final cacheSettingsProvider =
|
||||
AsyncNotifierProvider<CacheSettingsController, CacheSettings>(
|
||||
CacheSettingsController.new);
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Online if at least one connectivity result is non-none.
|
||||
/// connectivity_plus reports the union (wifi, mobile, vpn, etc.) — operator
|
||||
/// chose "no Wi-Fi gate" for #357, so any connection means "go ahead and
|
||||
/// pull/cache".
|
||||
///
|
||||
/// IMPORTANT: onConnectivityChanged only emits on *changes*, not on
|
||||
/// subscription. Without seeding an initial value via checkConnectivity(),
|
||||
/// any consumer using `ref.read(connectivityProvider.future)` would
|
||||
/// block until the OS happened to report a connectivity flip — which
|
||||
/// is exactly what made album/artist/playlist detail screens spin
|
||||
/// forever for tiles tapped before the first event landed.
|
||||
final connectivityProvider = StreamProvider<bool>((ref) async* {
|
||||
final c = Connectivity();
|
||||
bool isOnline(List<ConnectivityResult> rs) =>
|
||||
rs.any((r) => r != ConnectivityResult.none);
|
||||
|
||||
// checkConnectivity() goes over a platform channel and on some
|
||||
// Android builds it can stall. Fall back to "assume online" after
|
||||
// 2s so consumers waiting on .future never block forever — being
|
||||
// wrong about connectivity costs at most one failed request, but
|
||||
// being stuck spins the UI indefinitely.
|
||||
try {
|
||||
final initial = await c.checkConnectivity().timeout(
|
||||
const Duration(seconds: 2),
|
||||
onTimeout: () => const [ConnectivityResult.wifi],
|
||||
);
|
||||
yield isOnline(initial);
|
||||
} catch (_) {
|
||||
yield true;
|
||||
}
|
||||
yield* c.onConnectivityChanged.map(isOnline);
|
||||
});
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
// Drift database for Minstrel's offline cache (#357 plan B).
|
||||
//
|
||||
// Two cache layers in one database:
|
||||
// - Metadata cache (CachedArtists, CachedAlbums, CachedTracks,
|
||||
// CachedLikes, CachedPlaylists, CachedPlaylistTracks) — populated
|
||||
// by SyncController via /api/library/sync
|
||||
// - Audio cache index (AudioCacheIndex) — owned by AudioCacheManager
|
||||
//
|
||||
// Plus SyncMetadata holding the latest sync cursor.
|
||||
//
|
||||
// All entity ids are TEXT (server-side UUIDs serialized as strings).
|
||||
// build_runner generates db.g.dart from this file; it's gitignored and
|
||||
// regenerated in CI.
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
|
||||
part 'db.g.dart';
|
||||
|
||||
class CachedArtists extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get sortName => text()();
|
||||
TextColumn get mbid => text().nullable()();
|
||||
TextColumn get artistThumbPath => text().nullable()();
|
||||
TextColumn get artistFanartPath => text().nullable()();
|
||||
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedAlbums extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get artistId => text()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get sortTitle => text()();
|
||||
TextColumn get releaseDate => text().nullable()();
|
||||
TextColumn get coverPath => text().nullable()();
|
||||
TextColumn get mbid => text().nullable()();
|
||||
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedTracks extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get albumId => text()();
|
||||
TextColumn get artistId => text()();
|
||||
TextColumn get title => text()();
|
||||
IntColumn get durationMs => integer().withDefault(const Constant(0))();
|
||||
IntColumn get trackNumber => integer().nullable()();
|
||||
IntColumn get discNumber => integer().nullable()();
|
||||
TextColumn get filePath => text().nullable()();
|
||||
TextColumn get fileFormat => text().nullable()();
|
||||
TextColumn get genre => text().nullable()();
|
||||
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedLikes extends Table {
|
||||
TextColumn get userId => text()();
|
||||
TextColumn get entityType => text()(); // 'track' | 'album' | 'artist'
|
||||
TextColumn get entityId => text()();
|
||||
DateTimeColumn get likedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {userId, entityType, entityId};
|
||||
}
|
||||
|
||||
class CachedPlaylists extends Table {
|
||||
TextColumn get id => text()();
|
||||
TextColumn get userId => text()();
|
||||
TextColumn get name => text()();
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
BoolColumn get isPublic => boolean().withDefault(const Constant(false))();
|
||||
TextColumn get coverPath => text().nullable()();
|
||||
IntColumn get trackCount => integer().withDefault(const Constant(0))();
|
||||
IntColumn get durationSec => integer().withDefault(const Constant(0))();
|
||||
/// Server's system_variant: null for user playlists, "for_you" /
|
||||
/// "songs_like_artist" / "discover" for system-generated mixes.
|
||||
/// Added in schemaVersion 2 to let the add-to-playlist sheet filter
|
||||
/// out system playlists locally without a REST round-trip.
|
||||
TextColumn get systemVariant => text().nullable()();
|
||||
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedPlaylistTracks extends Table {
|
||||
TextColumn get playlistId => text()();
|
||||
TextColumn get trackId => text()();
|
||||
IntColumn get position => integer().withDefault(const Constant(0))();
|
||||
@override
|
||||
Set<Column> get primaryKey => {playlistId, trackId};
|
||||
}
|
||||
|
||||
/// One row per fully-downloaded audio file. `source` drives tiered LRU
|
||||
/// eviction; `incidental` evicts first, `manual` last.
|
||||
class AudioCacheIndex extends Table {
|
||||
TextColumn get trackId => text()();
|
||||
TextColumn get path => text()();
|
||||
IntColumn get sizeBytes => integer()();
|
||||
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
TextColumn get source => textEnum<CacheSource>()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {trackId};
|
||||
}
|
||||
|
||||
/// Single-row table holding the latest sync cursor.
|
||||
class SyncMetadata extends Table {
|
||||
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||
IntColumn get cursor => integer().withDefault(const Constant(0))();
|
||||
DateTimeColumn get lastSyncAt => dateTime().nullable()();
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
CachedArtists,
|
||||
CachedAlbums,
|
||||
CachedTracks,
|
||||
CachedLikes,
|
||||
CachedPlaylists,
|
||||
CachedPlaylistTracks,
|
||||
AudioCacheIndex,
|
||||
SyncMetadata,
|
||||
])
|
||||
class AppDb extends _$AppDb {
|
||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) => m.createAll(),
|
||||
onUpgrade: (m, from, to) async {
|
||||
if (from < 2) {
|
||||
// Schema 2: add CachedPlaylists.systemVariant. Existing
|
||||
// rows get null and the next sync rebuilds them with the
|
||||
// server's system_variant value.
|
||||
await m.addColumn(cachedPlaylists, cachedPlaylists.systemVariant);
|
||||
// Reset cursor so the next /api/library/sync re-emits all
|
||||
// playlists; otherwise the new column would stay null on
|
||||
// pre-existing rows until they happen to change server-side.
|
||||
await customStatement('UPDATE sync_metadata SET cursor = 0');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart';
|
||||
import '../models/home_data.dart';
|
||||
|
||||
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||
/// purpose: only warms artistProvider rows (single row, single round
|
||||
/// trip per id) and only ever fires once per id per session. Album
|
||||
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
|
||||
/// list when missing, and pre-warming N albums fans out N parallel
|
||||
/// "fetch tracks" round trips that compete with the user's actual
|
||||
/// playback request for bandwidth.
|
||||
class MetadataPrefetcher {
|
||||
MetadataPrefetcher(this._ref) {
|
||||
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
|
||||
next.whenData(_warmHome);
|
||||
});
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// Per-session dedupe so re-rendering a screen (every UI rebuild
|
||||
/// fires the data: callback) doesn't trigger N more fetches for
|
||||
/// ids we've already warmed.
|
||||
final Set<String> _warmedArtists = {};
|
||||
|
||||
/// Cap per section. Covers what fits on screen without scrolling.
|
||||
static const _topN = 8;
|
||||
|
||||
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||
/// see class comment.
|
||||
void warmArtists(Iterable<String> ids) {
|
||||
var n = 0;
|
||||
for (final id in ids) {
|
||||
if (id.isEmpty) continue;
|
||||
if (!_warmedArtists.add(id)) continue; // already warmed
|
||||
if (n++ >= _topN) break;
|
||||
_swallow(_ref.read(artistProvider(id).future));
|
||||
}
|
||||
}
|
||||
|
||||
void _warmHome(HomeData h) {
|
||||
final artistIds = <String>{};
|
||||
for (final a in h.recentlyAddedAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final a in h.rediscoverAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final ar in h.rediscoverArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final ar in h.lastPlayedArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final t in h.mostPlayedTracks.take(_topN)) {
|
||||
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
|
||||
}
|
||||
warmArtists(artistIds);
|
||||
}
|
||||
|
||||
/// Discards the return value and any error from a fire-and-forget
|
||||
/// provider read. We don't care about the value here — we only want
|
||||
/// the side effect of writing drift.
|
||||
void _swallow(Future<Object?> f) {
|
||||
f.then<void>((_) {}).onError((_, __) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start to activate the prefetcher (e.g. wire it
|
||||
/// from a top-level Consumer or main.dart container override).
|
||||
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
|
||||
return MetadataPrefetcher(ref);
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../player/player_provider.dart';
|
||||
import 'audio_cache_manager.dart';
|
||||
import 'cache_settings_provider.dart';
|
||||
import 'db.dart';
|
||||
|
||||
/// Listens to the player's currently-playing track. When it changes,
|
||||
/// computes the next-N tracks ahead in the queue and pins them via
|
||||
/// AudioCacheManager (source: autoPrefetch). After pinning, runs an
|
||||
/// eviction pass against the operator-set cap.
|
||||
///
|
||||
/// The window N comes from cacheSettingsProvider.prefetchWindow
|
||||
/// (default 5, configurable in Settings).
|
||||
class Prefetcher {
|
||||
Prefetcher(this._ref) {
|
||||
// The mediaItem stream changes whenever the active track changes
|
||||
// (skipNext/skipPrev or natural progression). Settings changes (e.g.
|
||||
// operator bumps prefetch window) also trigger a reconcile.
|
||||
_ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (_, __) => _reconcile());
|
||||
_ref.listen<AsyncValue<CacheSettings>>(cacheSettingsProvider, (_, __) => _reconcile());
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> _reconcile() async {
|
||||
final settings = _ref.read(cacheSettingsProvider).value;
|
||||
if (settings == null) return;
|
||||
|
||||
final queue = _ref.read(queueProvider).value ?? const <MediaItem>[];
|
||||
final current = _ref.read(mediaItemProvider).value;
|
||||
if (queue.isEmpty || current == null) return;
|
||||
|
||||
final currentIdx = queue.indexWhere((m) => m.id == current.id);
|
||||
if (currentIdx < 0) return;
|
||||
|
||||
final endIdx =
|
||||
(currentIdx + settings.prefetchWindow).clamp(0, queue.length - 1);
|
||||
|
||||
final mgr = _ref.read(audioCacheManagerProvider);
|
||||
|
||||
for (var i = currentIdx; i <= endIdx; i++) {
|
||||
final trackId = queue[i].id;
|
||||
if (await mgr.isCached(trackId)) continue;
|
||||
// Fire-and-forget; downloads happen in the background. Errors
|
||||
// inside pin() are caught + cleaned up internally.
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
||||
}
|
||||
|
||||
// Eviction pass after pinning new files.
|
||||
if (settings.capBytes > 0) {
|
||||
await mgr.evict(targetBytes: settings.capBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read this provider once at app start to activate the prefetcher.
|
||||
/// Constructor wires the listeners.
|
||||
final prefetcherProvider = Provider<Prefetcher>((ref) {
|
||||
return Prefetcher(ref);
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import 'audio_cache_manager.dart' show appDbProvider;
|
||||
import 'db.dart';
|
||||
|
||||
/// Counts returned from a sync operation. Surfaced to the operator-
|
||||
/// facing "Sync now" button + Settings card.
|
||||
class SyncResult {
|
||||
const SyncResult({
|
||||
required this.upserts,
|
||||
required this.deletes,
|
||||
required this.cursor,
|
||||
});
|
||||
final int upserts;
|
||||
final int deletes;
|
||||
final int cursor;
|
||||
}
|
||||
|
||||
/// Drives the delta-sync against the server (#357 Plan A endpoint).
|
||||
/// Reads cursor from drift, calls `/api/library/sync?since={cursor}`,
|
||||
/// applies upserts + deletes, advances cursor.
|
||||
class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
@override
|
||||
Future<SyncResult?> build() async => null;
|
||||
|
||||
Future<SyncResult?> sync() async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final db = ref.read(appDbProvider);
|
||||
final dio = await ref.read(dioProvider.future);
|
||||
|
||||
final meta = await db.select(db.syncMetadata).getSingleOrNull();
|
||||
final cursor = meta?.cursor ?? 0;
|
||||
|
||||
final resp = await dio.get<dynamic>(
|
||||
'/api/library/sync',
|
||||
queryParameters: {'since': cursor},
|
||||
);
|
||||
|
||||
// 204 No Content — no changes since cursor.
|
||||
if (resp.statusCode == 204) {
|
||||
await db.into(db.syncMetadata).insertOnConflictUpdate(
|
||||
SyncMetadataCompanion.insert(
|
||||
lastSyncAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
final r = SyncResult(upserts: 0, deletes: 0, cursor: cursor);
|
||||
state = AsyncData(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// 410 Gone — cursor too old, server has compacted past it. Reset
|
||||
// local state and retry once with cursor=0.
|
||||
if (resp.statusCode == 410) {
|
||||
await db.transaction(() async {
|
||||
await db.delete(db.cachedArtists).go();
|
||||
await db.delete(db.cachedAlbums).go();
|
||||
await db.delete(db.cachedTracks).go();
|
||||
await db.delete(db.cachedLikes).go();
|
||||
await db.delete(db.cachedPlaylists).go();
|
||||
await db.delete(db.cachedPlaylistTracks).go();
|
||||
await db.into(db.syncMetadata).insertOnConflictUpdate(
|
||||
SyncMetadataCompanion.insert(cursor: const drift.Value(0)),
|
||||
);
|
||||
});
|
||||
return sync();
|
||||
}
|
||||
|
||||
final body = resp.data as Map<String, dynamic>;
|
||||
final newCursor = (body['cursor'] as num).toInt();
|
||||
final upserts = body['upserts'] as Map<String, dynamic>? ?? {};
|
||||
final deletes = body['deletes'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
var upsertCount = 0;
|
||||
var deleteCount = 0;
|
||||
|
||||
await db.transaction(() async {
|
||||
// ---- Upserts ----
|
||||
for (final a in (upserts['artist'] as List? ?? const [])) {
|
||||
await db.into(db.cachedArtists).insertOnConflictUpdate(
|
||||
_artistFromJson(a as Map<String, dynamic>),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final a in (upserts['album'] as List? ?? const [])) {
|
||||
await db.into(db.cachedAlbums).insertOnConflictUpdate(
|
||||
_albumFromJson(a as Map<String, dynamic>),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final t in (upserts['track'] as List? ?? const [])) {
|
||||
await db.into(db.cachedTracks).insertOnConflictUpdate(
|
||||
_trackFromJson(t as Map<String, dynamic>),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final l in (upserts['like_track'] as List? ?? const [])) {
|
||||
final m = l as Map<String, dynamic>;
|
||||
await db.into(db.cachedLikes).insertOnConflictUpdate(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: m['user_id'] as String,
|
||||
entityType: 'track',
|
||||
entityId: m['track_id'] as String,
|
||||
),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final l in (upserts['like_album'] as List? ?? const [])) {
|
||||
final m = l as Map<String, dynamic>;
|
||||
await db.into(db.cachedLikes).insertOnConflictUpdate(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: m['user_id'] as String,
|
||||
entityType: 'album',
|
||||
entityId: m['album_id'] as String,
|
||||
),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final l in (upserts['like_artist'] as List? ?? const [])) {
|
||||
final m = l as Map<String, dynamic>;
|
||||
await db.into(db.cachedLikes).insertOnConflictUpdate(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: m['user_id'] as String,
|
||||
entityType: 'artist',
|
||||
entityId: m['artist_id'] as String,
|
||||
),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final p in (upserts['playlist'] as List? ?? const [])) {
|
||||
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
|
||||
_playlistFromJson(p as Map<String, dynamic>),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
for (final pt in (upserts['playlist_track'] as List? ?? const [])) {
|
||||
final m = pt as Map<String, dynamic>;
|
||||
await db.into(db.cachedPlaylistTracks).insertOnConflictUpdate(
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: m['playlist_id'] as String,
|
||||
trackId: m['track_id'] as String,
|
||||
),
|
||||
);
|
||||
upsertCount++;
|
||||
}
|
||||
|
||||
// ---- Deletes ----
|
||||
for (final id in (deletes['artist'] as List? ?? const [])) {
|
||||
await (db.delete(db.cachedArtists)
|
||||
..where((t) => t.id.equals(id as String)))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['album'] as List? ?? const [])) {
|
||||
await (db.delete(db.cachedAlbums)
|
||||
..where((t) => t.id.equals(id as String)))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['track'] as List? ?? const [])) {
|
||||
await (db.delete(db.cachedTracks)
|
||||
..where((t) => t.id.equals(id as String)))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['like_track'] as List? ?? const [])) {
|
||||
final parts = (id as String).split(':');
|
||||
if (parts.length != 2) continue;
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(parts[0]) &
|
||||
t.entityType.equals('track') &
|
||||
t.entityId.equals(parts[1])))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['like_album'] as List? ?? const [])) {
|
||||
final parts = (id as String).split(':');
|
||||
if (parts.length != 2) continue;
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(parts[0]) &
|
||||
t.entityType.equals('album') &
|
||||
t.entityId.equals(parts[1])))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['like_artist'] as List? ?? const [])) {
|
||||
final parts = (id as String).split(':');
|
||||
if (parts.length != 2) continue;
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(parts[0]) &
|
||||
t.entityType.equals('artist') &
|
||||
t.entityId.equals(parts[1])))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['playlist'] as List? ?? const [])) {
|
||||
await (db.delete(db.cachedPlaylists)
|
||||
..where((t) => t.id.equals(id as String)))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
for (final id in (deletes['playlist_track'] as List? ?? const [])) {
|
||||
final parts = (id as String).split(':');
|
||||
if (parts.length != 2) continue;
|
||||
await (db.delete(db.cachedPlaylistTracks)
|
||||
..where((t) =>
|
||||
t.playlistId.equals(parts[0]) &
|
||||
t.trackId.equals(parts[1])))
|
||||
.go();
|
||||
deleteCount++;
|
||||
}
|
||||
|
||||
// ---- Cursor + lastSyncAt ----
|
||||
await db.into(db.syncMetadata).insertOnConflictUpdate(
|
||||
SyncMetadataCompanion.insert(
|
||||
cursor: drift.Value(newCursor),
|
||||
lastSyncAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final result = SyncResult(
|
||||
upserts: upsertCount,
|
||||
deletes: deleteCount,
|
||||
cursor: newCursor,
|
||||
);
|
||||
state = AsyncData(result);
|
||||
return result;
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
CachedArtistsCompanion _artistFromJson(Map<String, dynamic> j) =>
|
||||
CachedArtistsCompanion.insert(
|
||||
id: j['id'] as String,
|
||||
name: (j['name'] as String?) ?? '',
|
||||
sortName: (j['sort_name'] as String?) ?? '',
|
||||
mbid: drift.Value(j['mbid'] as String?),
|
||||
artistThumbPath: drift.Value(j['artist_thumb_path'] as String?),
|
||||
artistFanartPath: drift.Value(j['artist_fanart_path'] as String?),
|
||||
);
|
||||
|
||||
CachedAlbumsCompanion _albumFromJson(Map<String, dynamic> j) =>
|
||||
CachedAlbumsCompanion.insert(
|
||||
id: j['id'] as String,
|
||||
artistId: j['artist_id'] as String,
|
||||
title: (j['title'] as String?) ?? '',
|
||||
sortTitle: (j['sort_title'] as String?) ?? '',
|
||||
releaseDate: drift.Value(j['release_date'] as String?),
|
||||
coverPath: drift.Value(j['cover_art_path'] as String?),
|
||||
mbid: drift.Value(j['mbid'] as String?),
|
||||
);
|
||||
|
||||
CachedTracksCompanion _trackFromJson(Map<String, dynamic> j) =>
|
||||
CachedTracksCompanion.insert(
|
||||
id: j['id'] as String,
|
||||
albumId: j['album_id'] as String,
|
||||
artistId: j['artist_id'] as String,
|
||||
title: (j['title'] as String?) ?? '',
|
||||
durationMs: drift.Value((j['duration_ms'] as num?)?.toInt() ?? 0),
|
||||
trackNumber: drift.Value((j['track_number'] as num?)?.toInt()),
|
||||
discNumber: drift.Value((j['disc_number'] as num?)?.toInt()),
|
||||
filePath: drift.Value(j['file_path'] as String?),
|
||||
fileFormat: drift.Value(j['file_format'] as String?),
|
||||
genre: drift.Value(j['genre'] as String?),
|
||||
);
|
||||
|
||||
CachedPlaylistsCompanion _playlistFromJson(Map<String, dynamic> j) =>
|
||||
CachedPlaylistsCompanion.insert(
|
||||
id: j['id'] as String,
|
||||
userId: j['user_id'] as String,
|
||||
name: (j['name'] as String?) ?? '',
|
||||
description: drift.Value((j['description'] as String?) ?? ''),
|
||||
isPublic: drift.Value((j['is_public'] as bool?) ?? false),
|
||||
coverPath: drift.Value(j['cover_path'] as String?),
|
||||
trackCount: drift.Value((j['track_count'] as num?)?.toInt() ?? 0),
|
||||
durationSec: drift.Value((j['duration_sec'] as num?)?.toInt() ?? 0),
|
||||
systemVariant: drift.Value(j['system_variant'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
final syncControllerProvider =
|
||||
AsyncNotifierProvider<SyncController, SyncResult?>(SyncController.new);
|
||||
@@ -0,0 +1,264 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/discover.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/lidarr.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
|
||||
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
||||
return DiscoverApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||
const DiscoverScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||
}
|
||||
|
||||
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final _ctrl = TextEditingController();
|
||||
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
||||
Future<List<LidarrSearchResult>>? _resultsFuture;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _runSearch() {
|
||||
final q = _ctrl.text.trim();
|
||||
if (q.isEmpty) {
|
||||
setState(() => _resultsFuture = null);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_resultsFuture = ref
|
||||
.read(_discoverApiProvider.future)
|
||||
.then((api) => api.search(q, _kind));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _request(LidarrSearchResult row) async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
try {
|
||||
final api = await ref.read(_discoverApiProvider.future);
|
||||
await api.createRequest(
|
||||
kind: _kind,
|
||||
artistMbid: _kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
||||
artistName: _kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
||||
albumMbid: _kind == LidarrRequestKind.album ? row.mbid : null,
|
||||
albumTitle: _kind == LidarrRequestKind.album ? row.name : null,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Requested: ${row.name}'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
// Re-run search to refresh the `requested` flag on the row.
|
||||
_runSearch();
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final code = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Request failed: $code'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/discover')],
|
||||
),
|
||||
body: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
cursorColor: fs.accent,
|
||||
onSubmitted: (_) => _runSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search Lidarr for new music',
|
||||
hintStyle: TextStyle(color: fs.ash),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.iron),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.accent),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.search, color: fs.ash),
|
||||
onPressed: _runSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: SegmentedButton<LidarrRequestKind>(
|
||||
segments: const [
|
||||
ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')),
|
||||
ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')),
|
||||
],
|
||||
selected: {_kind},
|
||||
onSelectionChanged: (s) {
|
||||
setState(() {
|
||||
_kind = s.first;
|
||||
if (_ctrl.text.trim().isNotEmpty) _runSearch();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _resultsFuture == null
|
||||
? Center(
|
||||
child: Text(
|
||||
'Type to search, then tap Request to send to Lidarr.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
: FutureBuilder<List<LidarrSearchResult>>(
|
||||
future: _resultsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
final err = snap.error;
|
||||
final code = err is DioException
|
||||
? ApiError.fromDio(err).code
|
||||
: 'unknown';
|
||||
return Center(
|
||||
child: Text('Search failed: $code',
|
||||
style: TextStyle(color: fs.error)),
|
||||
);
|
||||
}
|
||||
final rows = snap.data ?? const [];
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing to add for that search yet.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
textAlign: TextAlign.center),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _ResultTile(
|
||||
row: rows[i],
|
||||
onRequest: () => _request(rows[i]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultTile extends StatelessWidget {
|
||||
const _ResultTile({required this.row, required this.onRequest});
|
||||
final LidarrSearchResult row;
|
||||
final VoidCallback onRequest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final disabled = row.inLibrary || row.requested;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: row.imageUrl.isEmpty
|
||||
? Icon(Icons.album, color: fs.ash)
|
||||
: Image.network(row.imageUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.album, color: fs.ash)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(row.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
if (row.secondaryText.isNotEmpty)
|
||||
Text(row.secondaryText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (row.inLibrary)
|
||||
_Pill(label: 'In library', color: fs.ash)
|
||||
else if (row.requested)
|
||||
_Pill(label: 'Requested', color: fs.ash)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: disabled ? null : onRequest,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: const Text('Request'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(label, style: TextStyle(color: color, fontSize: 11)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,105 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class AlbumDetailScreen extends ConsumerWidget {
|
||||
const AlbumDetailScreen({required this.id, super.key});
|
||||
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional album reference passed by the caller (typically the
|
||||
/// tile they tapped) so the header can render immediately while
|
||||
/// the full provider loads tracks. Hydration only — provider data
|
||||
/// still wins once it arrives.
|
||||
final AlbumRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final live = ref.watch(albumProvider(id));
|
||||
|
||||
// Pick the best header info available: live data > seed > nothing.
|
||||
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||
? live.value!.album.title
|
||||
: seed?.title ?? '';
|
||||
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||
? live.value!.album.artistName
|
||||
: seed?.artistName ?? '';
|
||||
|
||||
Widget header() => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
if (headerArtist.isNotEmpty)
|
||||
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: ref.watch(albumProvider(id)).when(
|
||||
body: live.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => seed != null
|
||||
? ListView(children: [
|
||||
header(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
])
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
data: (r) => ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(width: 96, height: 96, color: fs.slate),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -38,6 +113,21 @@ class AlbumDetailScreen extends ConsumerWidget {
|
||||
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
key: const Key('download_album_button'),
|
||||
icon: Icon(Icons.download, color: fs.ash),
|
||||
tooltip: 'Download album',
|
||||
onPressed: () {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
for (final t in r.tracks) {
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(t.id, source: CacheSource.autoPlaylist);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||
|
||||
@@ -5,34 +5,75 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
|
||||
class ArtistDetailScreen extends ConsumerWidget {
|
||||
const ArtistDetailScreen({required this.id, super.key});
|
||||
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional artist reference from the caller. Lets the screen render
|
||||
/// the name + avatar immediately while albums load.
|
||||
final ArtistRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(artistAlbumsProvider(id));
|
||||
|
||||
// Resolve which artist info to render in the header. Live wins
|
||||
// when present and populated; seed fills the gap during the
|
||||
// first frame after navigation.
|
||||
final liveArtist = artist.value;
|
||||
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: artist.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (a) => ListView(children: [
|
||||
// While loading: render header from seed if available so the
|
||||
// page isn't blank.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _artistBody(context, ref, seed!, albums, fs),
|
||||
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ArtistRef a,
|
||||
AsyncValue<List<AlbumRef>> albums,
|
||||
FabledSwordTheme fs,
|
||||
) =>
|
||||
ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 96, height: 96,
|
||||
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
// Server derives artist cover from a representative
|
||||
// album. Drift cache doesn't persist that pointer, so
|
||||
// mirror the trick client-side: reuse the first album
|
||||
// returned by artistAlbumsProvider. Falls back to
|
||||
// slate while albums are loading or empty.
|
||||
child: _ArtistAvatar(
|
||||
serverCoverUrl: a.coverUrl,
|
||||
albums: albums,
|
||||
fs: fs,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -51,10 +92,31 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
||||
if (tracks.isEmpty) return;
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
||||
try {
|
||||
final tracks =
|
||||
await ref.read(artistTracksProvider(id).future);
|
||||
if (tracks.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No tracks found for this artist yet.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(shuffled);
|
||||
} catch (e) {
|
||||
debugPrint('artist_detail: play failed: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Couldn't start playback: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -68,23 +130,79 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
albums.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||
data: (list) => GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
|
||||
},
|
||||
),
|
||||
data: (list) {
|
||||
return LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW =
|
||||
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||
cols;
|
||||
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||
// ≈ 36) + slack. Artist line is suppressed in this
|
||||
// grid (showArtist: false) since the page header already
|
||||
// names the artist. Slack is generous on purpose — line-
|
||||
// height variations would otherwise overflow by 1px.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 8;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
showArtist: false,
|
||||
onTap: () =>
|
||||
context.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||
/// non-empty; otherwise we mirror the server's "use the first album's
|
||||
/// cover" rule client-side via the loaded album list.
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({
|
||||
required this.serverCoverUrl,
|
||||
required this.albums,
|
||||
required this.fs,
|
||||
});
|
||||
final String serverCoverUrl;
|
||||
final AsyncValue<List<AlbumRef>> albums;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (serverCoverUrl.isNotEmpty) {
|
||||
return ServerImage(
|
||||
url: serverCoverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
||||
if (firstId == null) {
|
||||
return Container(color: fs.slate);
|
||||
}
|
||||
return ServerImage(
|
||||
url: '/api/albums/$firstId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../playlists/playlists_provider.dart';
|
||||
import '../playlists/widgets/playlist_card.dart';
|
||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||
import '../shared/widgets/connection_error_banner.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
import 'widgets/artist_card.dart';
|
||||
import 'widgets/compact_track_card.dart';
|
||||
import 'widgets/horizontal_scroll_row.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -22,67 +27,394 @@ class HomeScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final home = ref.watch(homeProvider);
|
||||
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
||||
final status = ref.watch(systemPlaylistsStatusProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ref.watch(homeProvider).when(
|
||||
error: (e, _) {
|
||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||
if (code == 'connection_refused') {
|
||||
return ConnectionErrorBanner(
|
||||
onRetry: () => ref.refresh(homeProvider),
|
||||
);
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(children: [
|
||||
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
|
||||
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
|
||||
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
|
||||
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
|
||||
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
),
|
||||
child: home.when(
|
||||
error: (e, _) {
|
||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||
if (code == 'connection_refused') {
|
||||
return ConnectionErrorBanner(
|
||||
onRetry: () => ref.refresh(homeProvider),
|
||||
);
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => _HomeSkeleton(fs: fs),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(
|
||||
// ClampingScrollPhysics: no bounce overscroll past the
|
||||
// content. Combined with the bottom padding below, this
|
||||
// makes "scroll to end" land the last item just above the
|
||||
// player bar — no empty void.
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albums: h.rediscoverAlbums,
|
||||
artists: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
||||
// Bottom padding ≈ player bar height (top row 80 + seek
|
||||
// 30 + padding 16 ≈ ~140) so the last section's bottom
|
||||
// edge lands right above the player when scrolled.
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _albumsRow(BuildContext ctx, String title, List<AlbumRef> albums) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
children: [
|
||||
for (final a in albums)
|
||||
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
children: [
|
||||
for (final a in artists)
|
||||
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
height: 64,
|
||||
children: [
|
||||
for (final t in tracks)
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: TrackRow(
|
||||
track: t,
|
||||
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaylistsSection extends StatelessWidget {
|
||||
const _PlaylistsSection({required this.playlists, required this.status});
|
||||
final List<Playlist> playlists;
|
||||
final SystemPlaylistsStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _buildPlaylistsRow(playlists, status);
|
||||
return HorizontalScrollRow(
|
||||
title: 'Playlists',
|
||||
height: 220,
|
||||
children: items.map((item) {
|
||||
if (item is _RealPlaylist) {
|
||||
return PlaylistCard(playlist: item.playlist);
|
||||
}
|
||||
final ph = item as _PlaceholderPlaylist;
|
||||
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _PlaylistRowItem {}
|
||||
class _RealPlaylist extends _PlaylistRowItem {
|
||||
_RealPlaylist(this.playlist);
|
||||
final Playlist playlist;
|
||||
}
|
||||
class _PlaceholderPlaylist extends _PlaylistRowItem {
|
||||
_PlaceholderPlaylist(this.label, this.variant);
|
||||
final String label;
|
||||
final String variant;
|
||||
}
|
||||
|
||||
List<_PlaylistRowItem> _buildPlaylistsRow(
|
||||
List<Playlist> ownedAll,
|
||||
SystemPlaylistsStatus status,
|
||||
) {
|
||||
final out = <_PlaylistRowItem>[];
|
||||
|
||||
Playlist? findFirst(bool Function(Playlist) test) {
|
||||
for (final p in ownedAll) {
|
||||
if (test(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Slot 1: For-You.
|
||||
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
||||
out.add(forYou != null
|
||||
? _RealPlaylist(forYou)
|
||||
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
||||
|
||||
// Slot 2: Discover. Server emits this with system_variant='discover'
|
||||
// when the recommendation engine has eligible tracks; before then,
|
||||
// show a placeholder in the same "building/pending" state machine
|
||||
// as For-You.
|
||||
final discover = findFirst((p) => p.systemVariant == 'discover');
|
||||
out.add(discover != null
|
||||
? _RealPlaylist(discover)
|
||||
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
|
||||
|
||||
// Slots 3-5: Songs-like (real first, padded to 3).
|
||||
final songsLike = ownedAll
|
||||
.where((p) => p.systemVariant == 'songs_like_artist')
|
||||
.take(3)
|
||||
.toList();
|
||||
for (var i = 0; i < 3; i++) {
|
||||
out.add(i < songsLike.length
|
||||
? _RealPlaylist(songsLike[i])
|
||||
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
|
||||
}
|
||||
|
||||
// User-created trail (server returns most-recently-updated first).
|
||||
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
|
||||
out.add(_RealPlaylist(p));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
String _variantFor(String slot, SystemPlaylistsStatus s) {
|
||||
if (s.inFlight) return 'building';
|
||||
if (s.lastError != null) return 'failed';
|
||||
if (slot == 'songs-like' && s.lastRunAt != null) return 'seed-needed';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
class _RecentlyAddedSection extends StatelessWidget {
|
||||
const _RecentlyAddedSection({required this.albums});
|
||||
final List<AlbumRef> albums;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (albums.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Recently added',
|
||||
message: "Nothing added yet. Scan a folder via the server's config.",
|
||||
);
|
||||
}
|
||||
final controller = ScrollController();
|
||||
final rows = _chunk(albums, 25);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
HorizontalScrollRow(
|
||||
title: i == 0 ? 'Recently added' : '',
|
||||
controller: controller,
|
||||
children: rows[i]
|
||||
.map((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
context.push('/albums/${a.id}', extra: a),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _RediscoverSection extends StatelessWidget {
|
||||
const _RediscoverSection({required this.albums, required this.artists});
|
||||
final List<AlbumRef> albums;
|
||||
final List<ArtistRef> artists;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (albums.isEmpty && artists.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Rediscover',
|
||||
message:
|
||||
'No forgotten favourites yet. Like some albums or artists to fill this in.',
|
||||
);
|
||||
}
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (albums.isNotEmpty)
|
||||
HorizontalScrollRow(
|
||||
title: 'Rediscover',
|
||||
children: albums
|
||||
.map((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
context.push('/albums/${a.id}', extra: a),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
if (artists.isNotEmpty)
|
||||
HorizontalScrollRow(
|
||||
title: albums.isEmpty ? 'Rediscover' : '',
|
||||
height: 168,
|
||||
children: artists
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () =>
|
||||
context.push('/artists/${ar.id}', extra: ar),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _MostPlayedSection extends StatelessWidget {
|
||||
const _MostPlayedSection({required this.tracks});
|
||||
final List<TrackRef> tracks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tracks.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Most played',
|
||||
message: 'No plays to draw from. Listen to something.',
|
||||
);
|
||||
}
|
||||
final controller = ScrollController();
|
||||
final rows = _chunk(tracks, 25);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
HorizontalScrollRow(
|
||||
title: i == 0 ? 'Most played' : '',
|
||||
height: 64,
|
||||
controller: controller,
|
||||
children: [
|
||||
for (var j = 0; j < rows[i].length; j++)
|
||||
CompactTrackCard(
|
||||
track: rows[i][j],
|
||||
sectionTracks: tracks,
|
||||
index: i * 25 + j,
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _LastPlayedSection extends StatelessWidget {
|
||||
const _LastPlayedSection({required this.artists});
|
||||
final List<ArtistRef> artists;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (artists.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Last played',
|
||||
message: 'No recent plays.',
|
||||
);
|
||||
}
|
||||
return HorizontalScrollRow(
|
||||
title: 'Last played',
|
||||
height: 168,
|
||||
children: artists
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () =>
|
||||
context.push('/artists/${ar.id}', extra: ar),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptySection extends StatelessWidget {
|
||||
const _EmptySection({required this.title, required this.message});
|
||||
final String title;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(message, style: TextStyle(color: fs.ash)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cold-start skeleton. Renders the same shape as the real home (a few
|
||||
/// section titles + card-sized placeholders) so the page feels alive
|
||||
/// while /api/home is in flight, instead of a 30-second blank spinner.
|
||||
/// Each section drops in independently as data arrives via the
|
||||
/// individual providers.
|
||||
class _HomeSkeleton extends StatelessWidget {
|
||||
const _HomeSkeleton({required this.fs});
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_SkeletonSection(fs: fs, title: 'Playlists', cardWidth: 176),
|
||||
_SkeletonSection(fs: fs, title: 'Recently added', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Rediscover', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Most played', cardWidth: 140),
|
||||
_SkeletonSection(fs: fs, title: 'Last played', cardWidth: 140),
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonSection extends StatelessWidget {
|
||||
const _SkeletonSection({
|
||||
required this.fs,
|
||||
required this.title,
|
||||
required this.cardWidth,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final String title;
|
||||
final double cardWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final coverSize = cardWidth - 16;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: coverSize + 40,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 6,
|
||||
itemBuilder: (_, __) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.slate,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(width: coverSize * 0.7, height: 12, color: fs.slate),
|
||||
const SizedBox(height: 4),
|
||||
Container(width: coverSize * 0.4, height: 10, color: fs.iron),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
List<List<T>> _chunk<T>(List<T> items, int size) {
|
||||
final out = <List<T>>[];
|
||||
for (var i = 0; i < items.length; i += size) {
|
||||
out.add(items.sublist(i, i + size > items.length ? items.length : i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/client.dart';
|
||||
import '../api/endpoints/library.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/adapters.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/home_data.dart';
|
||||
@@ -37,24 +46,266 @@ final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
||||
});
|
||||
|
||||
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
||||
/// when empty + online, fetches via REST + populates drift, which
|
||||
/// re-emits via watch().
|
||||
final artistProvider =
|
||||
FutureProvider.family<ArtistRef, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
|
||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedArtist, ArtistRef>(
|
||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||
.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtist(id);
|
||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const ArtistRef(id: '', name: '')
|
||||
: rows.first.toRef(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// No alwaysRefresh: artist rows don't change frequently, and the
|
||||
// metadata prefetcher creates many subscriptions in parallel —
|
||||
// each silently re-fetching once would saturate the request
|
||||
// pipeline behind the user's actual playback request.
|
||||
tag: 'artist($id)',
|
||||
);
|
||||
});
|
||||
|
||||
final artistAlbumsProvider =
|
||||
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
|
||||
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
// Join cached_albums + cached_artists to fill artist_name on each row.
|
||||
final query = db.select(db.cachedAlbums).join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||
])..where(db.cachedAlbums.artistId.equals(artistId));
|
||||
|
||||
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtistAlbums(artistId);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
|
||||
});
|
||||
},
|
||||
toResult: (rows) => rows.map((r) {
|
||||
final album = r.readTable(db.cachedAlbums);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
return album.toRef(artistName: artist?.name ?? '');
|
||||
}).toList(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// SWR: re-fetch the full album list on every visit. Without this,
|
||||
// a previously-incomplete drift cache (e.g. user had only opened
|
||||
// one album by this artist, so cachedAlbums had just that row)
|
||||
// would render forever as a partial list. The prefetcher only
|
||||
// warms artistProvider (single row), so this provider isn't
|
||||
// mass-instantiated and the storm risk that motivated dropping
|
||||
// alwaysRefresh elsewhere doesn't apply here.
|
||||
alwaysRefresh: true,
|
||||
tag: 'artistAlbums($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
final artistTracksProvider =
|
||||
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
|
||||
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final query = db.select(db.cachedTracks).join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||
])..where(db.cachedTracks.artistId.equals(artistId));
|
||||
|
||||
return cacheFirst<drift.TypedResult, List<TrackRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtistTracks(artistId);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
|
||||
});
|
||||
},
|
||||
toResult: (rows) => rows.map((r) {
|
||||
final track = r.readTable(db.cachedTracks);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
final album = r.readTableOrNull(db.cachedAlbums);
|
||||
return track.toRef(
|
||||
artistName: artist?.name ?? '',
|
||||
albumTitle: album?.title ?? '',
|
||||
);
|
||||
}).toList(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
tag: 'artistTracks($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
final albumProvider =
|
||||
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
|
||||
(ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
|
||||
},
|
||||
);
|
||||
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
||||
/// for reactive updates over both rows.
|
||||
final albumProvider = StreamProvider.family<
|
||||
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
|
||||
final db = ref.watch(appDbProvider);
|
||||
|
||||
final albumQuery = (db.select(db.cachedAlbums)
|
||||
..where((t) => t.id.equals(albumId)))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||
]);
|
||||
|
||||
final tracksQuery = (db.select(db.cachedTracks)
|
||||
..where((t) => t.albumId.equals(albumId))
|
||||
..orderBy([
|
||||
(t) => drift.OrderingTerm.asc(t.discNumber),
|
||||
(t) => drift.OrderingTerm.asc(t.trackNumber),
|
||||
]))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
]);
|
||||
|
||||
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
||||
// server genuinely returns zero tracks (or if the fetch fails).
|
||||
var fetchAttempted = false;
|
||||
// (revalidated flag removed; see SWR note below the yield.)
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-cache fetch: pulls the album + its tracks + any unique
|
||||
// artists referenced and writes all three tables in one batch.
|
||||
// Returns true on success (drift watch will re-emit), false on
|
||||
// failure so the caller can yield an empty result.
|
||||
Future<bool> fetchAndPopulate() async {
|
||||
try {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api
|
||||
.getAlbum(albumId)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
// Collect every artist mentioned by the album + its tracks so
|
||||
// the JOINs that drive both the album header and the track rows
|
||||
// have something to bind to. Without this, drift returns null
|
||||
// for the artist row and artistName surfaces empty — visible
|
||||
// as a missing artist line in the mini player and row list.
|
||||
final artists = <String, ArtistRef>{};
|
||||
if (fresh.album.artistId.isNotEmpty &&
|
||||
fresh.album.artistName.isNotEmpty) {
|
||||
artists[fresh.album.artistId] = ArtistRef(
|
||||
id: fresh.album.artistId,
|
||||
name: fresh.album.artistName,
|
||||
);
|
||||
}
|
||||
for (final t in fresh.tracks) {
|
||||
if (t.artistId.isNotEmpty &&
|
||||
t.artistName.isNotEmpty &&
|
||||
!artists.containsKey(t.artistId)) {
|
||||
artists[t.artistId] = ArtistRef(
|
||||
id: t.artistId,
|
||||
name: t.artistName,
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.batch((b) {
|
||||
if (artists.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
|
||||
}
|
||||
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
|
||||
b.insertAllOnConflictUpdate(db.cachedTracks,
|
||||
fresh.tracks.map((t) => t.toDrift()).toList());
|
||||
});
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await for (final albumRows in albumQuery.watch()) {
|
||||
// Case 1: no album row at all → cold-fetch.
|
||||
if (albumRows.isEmpty) {
|
||||
if (fetchAttempted) {
|
||||
// Already tried and got nothing back.
|
||||
yield (
|
||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||
tracks: const <TrackRef>[],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
fetchAttempted = true;
|
||||
if (!await isOnline()) {
|
||||
yield (
|
||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||
tracks: const <TrackRef>[],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final ok = await fetchAndPopulate();
|
||||
if (!ok) {
|
||||
yield (
|
||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||
tracks: const <TrackRef>[],
|
||||
);
|
||||
}
|
||||
// On success, drift watch re-emits with rows; loop continues.
|
||||
continue;
|
||||
}
|
||||
|
||||
final albumRow = albumRows.first;
|
||||
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
||||
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
|
||||
);
|
||||
|
||||
final trackRows = await tracksQuery.get();
|
||||
|
||||
// Case 2: album row exists but no tracks. This happens when an
|
||||
// upstream provider (artistAlbumsProvider, sync, etc.) populated
|
||||
// album rows without their track lists. Trigger the same
|
||||
// fetchAndPopulate so the album becomes complete; drift watch
|
||||
// re-emits and we land in the populated branch on the next pass.
|
||||
if (trackRows.isEmpty && !fetchAttempted) {
|
||||
fetchAttempted = true;
|
||||
if (await isOnline()) {
|
||||
final ok = await fetchAndPopulate();
|
||||
if (ok) continue; // wait for watch re-emit
|
||||
}
|
||||
// Fall through and yield with the album + empty tracks if the
|
||||
// fetch failed or we're offline.
|
||||
}
|
||||
|
||||
final tracks = trackRows.map((r) {
|
||||
final track = r.readTable(db.cachedTracks);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
return track.toRef(
|
||||
artistName: artist?.name ?? '',
|
||||
albumTitle: album.title,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Note: NO SWR here on purpose. Prior code kicked a background
|
||||
// refresh on every first cache hit, which combined with the
|
||||
// metadata prefetcher meant every prewarmed album id triggered
|
||||
// an extra fetch even when drift was already populated. Tracks
|
||||
// and album metadata don't change on the same timescale as
|
||||
// playlists; a stale read is fine until the user invalidates
|
||||
// (pull-to-refresh) or the album is genuinely re-fetched.
|
||||
|
||||
yield (album: album, tracks: tracks);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/library_lists.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../cache/metadata_prefetcher.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/history_event.dart';
|
||||
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
||||
// route descriptor) that conflicts with our wire-format Page<T>.
|
||||
import '../models/page.dart' as wire;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
import 'widgets/artist_card.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
// Providers scoped to this screen. Each tab gets its own first page.
|
||||
// Infinite scroll is a future enhancement; for v1 phone testing the
|
||||
// first 50 rows per tab is enough.
|
||||
|
||||
final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
|
||||
return LibraryListsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||
return LikesApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||
/// pages append to the existing items rather than replacing them.
|
||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<ArtistRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listArtists(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listArtists(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — the next near-bottom event will retry. The current
|
||||
// partial list stays visible.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||
_LibraryArtistsNotifier,
|
||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||
|
||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<AlbumRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listAlbums(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — next scroll event will retry.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||
_LibraryAlbumsNotifier,
|
||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||
|
||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).history();
|
||||
});
|
||||
|
||||
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
||||
});
|
||||
|
||||
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
||||
});
|
||||
|
||||
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
||||
});
|
||||
|
||||
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).quarantineMine();
|
||||
});
|
||||
|
||||
class LibraryScreen extends ConsumerStatefulWidget {
|
||||
const LibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late final TabController _ctrl = TabController(length: 5, vsync: this);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/library')],
|
||||
bottom: TabBar(
|
||||
controller: _ctrl,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
labelColor: fs.parchment,
|
||||
unselectedLabelColor: fs.ash,
|
||||
indicatorColor: fs.accent,
|
||||
tabs: const [
|
||||
Tab(text: 'Artists'),
|
||||
Tab(text: 'Albums'),
|
||||
Tab(text: 'History'),
|
||||
Tab(text: 'Liked'),
|
||||
Tab(text: 'Hidden'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _ctrl,
|
||||
children: const [
|
||||
_ArtistsTab(),
|
||||
_AlbumsTab(),
|
||||
_HistoryTab(),
|
||||
_LikedTab(),
|
||||
_HiddenTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtistsTab extends ConsumerWidget {
|
||||
const _ArtistsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_libraryArtistsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) {
|
||||
// Warm details for the first screenful so taps are instant.
|
||||
ref
|
||||
.read(metadataPrefetcherProvider)
|
||||
.warmArtists(page.items.map((a) => a.id));
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryArtistsProvider);
|
||||
await ref.read(_libraryArtistsProvider.future);
|
||||
},
|
||||
// Listen for scroll-near-bottom and ask the notifier
|
||||
// to fetch the next page. 800px lookahead so the next
|
||||
// page lands before the user reaches the visible end.
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryArtistsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = page.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => ctx.push('/artists/${artist.id}',
|
||||
extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumsTab extends ConsumerWidget {
|
||||
const _AlbumsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_libraryAlbumsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) {
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryAlbumsProvider);
|
||||
await ref.read(_libraryAlbumsProvider.future);
|
||||
},
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW = (constraints.maxWidth -
|
||||
sidePad * 2 -
|
||||
gap * (cols - 1)) /
|
||||
cols;
|
||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||
// + artist line (~16) + slack. Slack is generous on
|
||||
// purpose — line-height + font scaling variations
|
||||
// would otherwise overflow the cell by a pixel
|
||||
// (logged as a noisy RenderFlex warning).
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryAlbumsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(sidePad),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = page.items[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
onTap: () => ctx.push('/albums/${album.id}',
|
||||
extra: album),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTab extends ConsumerWidget {
|
||||
const _HistoryTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_historyProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.events.isEmpty
|
||||
? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_historyProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: page.events.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) {
|
||||
final event = page.events[i];
|
||||
return TrackRow(
|
||||
track: event.track,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks([event.track]),
|
||||
trailing: Text(
|
||||
_relativeTime(event.playedAt),
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LikedTab extends ConsumerWidget {
|
||||
const _LikedTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final tracksA = ref.watch(_likedTracksProvider);
|
||||
final albumsA = ref.watch(_likedAlbumsProvider);
|
||||
final artistsA = ref.watch(_likedArtistsProvider);
|
||||
|
||||
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final t = tracksA.value;
|
||||
final al = albumsA.value;
|
||||
final ar = artistsA.value;
|
||||
if (t == null || al == null || ar == null) {
|
||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||
}
|
||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
||||
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
ref.refresh(_likedTracksProvider.future),
|
||||
ref.refresh(_likedAlbumsProvider.future),
|
||||
ref.refresh(_likedArtistsProvider.future),
|
||||
]);
|
||||
},
|
||||
child: ListView(children: [
|
||||
if (ar.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Artists', count: ar.total),
|
||||
SizedBox(
|
||||
height: 168,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: ar.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = ar.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () =>
|
||||
ctx.push('/artists/${artist.id}', extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (al.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Albums', count: al.total),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: al.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = al.items[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
onTap: () =>
|
||||
ctx.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (t.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Tracks', count: t.total),
|
||||
...t.items.asMap().entries.map((e) {
|
||||
return TrackRow(
|
||||
track: e.value,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(t.items, initialIndex: e.key),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HiddenTab extends ConsumerWidget {
|
||||
const _HiddenTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ref.watch(_quarantineProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) => rows.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_quarantineProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuarantineTile extends StatelessWidget {
|
||||
const _QuarantineTile({required this.row});
|
||||
final QuarantineMineRow row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final reasonLabel = switch (row.reason) {
|
||||
'bad_rip' => 'Bad rip',
|
||||
'wrong_file' => 'Wrong file',
|
||||
'wrong_tags' => 'Wrong tags',
|
||||
'duplicate' => 'Duplicate',
|
||||
_ => 'Other',
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
row.trackTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.label, required this.count});
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
|
||||
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
|
||||
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
|
||||
String _relativeTime(String iso) {
|
||||
final t = DateTime.tryParse(iso);
|
||||
if (t == null) return '';
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(t);
|
||||
if (diff.inMinutes < 60) {
|
||||
final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
|
||||
return '${m}m ago';
|
||||
}
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final hh = t.hour.toString().padLeft(2, '0');
|
||||
final mm = t.minute.toString().padLeft(2, '0');
|
||||
return '${days[t.weekday - 1]} $hh:$mm';
|
||||
}
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
|
||||
return '${months[t.month - 1]} ${t.day}, ${t.year}';
|
||||
}
|
||||
@@ -2,48 +2,80 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
const AlbumCard({required this.album, required this.onTap, super.key});
|
||||
const AlbumCard({
|
||||
required this.album,
|
||||
required this.onTap,
|
||||
this.width = 140,
|
||||
this.titleMaxLines = 1,
|
||||
this.showArtist = true,
|
||||
super.key,
|
||||
});
|
||||
final AlbumRef album;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Outer width of the card. Cover is square at width - 16 (8dp
|
||||
/// horizontal padding either side). Default suits horizontal lists;
|
||||
/// grids should pass the cell width so the card scales down.
|
||||
final double width;
|
||||
|
||||
/// Lets callers (e.g. the artist detail grid) allow the title to
|
||||
/// wrap to a second line so it isn't truncated to a single
|
||||
/// ellipsized character at narrower widths.
|
||||
final int titleMaxLines;
|
||||
|
||||
/// Suppress the artist name. Useful on surfaces where the artist is
|
||||
/// already implied by the page header (e.g. artist detail).
|
||||
final bool showArtist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: album.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: Image.network(album.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
final coverSize = width - 16;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: fs.slate,
|
||||
child: album.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
fit: BoxFit.cover)
|
||||
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.title,
|
||||
maxLines: titleMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (showArtist && album.artistName.isNotEmpty)
|
||||
Text(
|
||||
album.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
album.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../models/artist.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
@@ -12,31 +13,35 @@ class ArtistCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: Image.network(artist.coverUrl, fit: BoxFit.cover),
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
fit: BoxFit.cover)
|
||||
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../cache/audio_cache_manager.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Small download glyph shown next to a track row when the track is
|
||||
/// cached locally. FutureBuilder one-shot — track rows are short-lived
|
||||
/// and cache state rarely changes mid-render.
|
||||
class CachedIndicator extends ConsumerWidget {
|
||||
const CachedIndicator({required this.trackId, super.key});
|
||||
final String trackId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return FutureBuilder<bool>(
|
||||
future: mgr.isCached(trackId),
|
||||
builder: (ctx, snap) {
|
||||
if (snap.data != true) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.download_done, size: 14, color: fs.accent),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Small horizontal track cell used by the home Most-played section.
|
||||
/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall).
|
||||
/// Tap plays from this track within [sectionTracks] starting at [index].
|
||||
class CompactTrackCard extends ConsumerWidget {
|
||||
const CompactTrackCard({
|
||||
super.key,
|
||||
required this.track,
|
||||
required this.sectionTracks,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final TrackRef track;
|
||||
|
||||
/// All tracks in the home section so play-from-here can queue them
|
||||
/// in order.
|
||||
final List<TrackRef> sectionTracks;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// Cover URL derives from the track's album. Mirrors web
|
||||
// CompactTrackCard which calls coverUrl(track.album_id).
|
||||
final coverUrl = track.albumId.isNotEmpty
|
||||
? '/api/albums/${track.albumId}/cover'
|
||||
: '';
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: InkWell(
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(sectionTracks, initialIndex: index),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: ServerImage(
|
||||
url: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TrackActionsButton(track: track),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,18 @@ class HorizontalScrollRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
if (title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: ListView(
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import 'cached_indicator.dart';
|
||||
|
||||
class TrackRow extends StatelessWidget {
|
||||
const TrackRow({
|
||||
required this.track,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
this.actions = true,
|
||||
super.key,
|
||||
});
|
||||
final TrackRef track;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Render the 3-dot TrackActionsButton at the end of the row. Default
|
||||
/// true; pass false in surfaces that don't want the menu (e.g. when
|
||||
/// showing a static read-only list).
|
||||
final bool actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
@@ -22,38 +30,48 @@ class TrackRow extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(children: [
|
||||
if (track.trackNumber != null)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
width: 22,
|
||||
child: Text(
|
||||
track.trackNumber.toString(),
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
// Skip the artist line entirely when empty so the row
|
||||
// height collapses to a single line of title — keeps
|
||||
// dense album views from looking padded.
|
||||
if (track.artistName.isNotEmpty)
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CachedIndicator(trackId: track.id),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (trailing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: trailing!,
|
||||
),
|
||||
if (actions) TrackActionsButton(track: track),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ class LikeButton extends ConsumerWidget {
|
||||
color: liked ? fs.accent : fs.ash,
|
||||
size: size,
|
||||
),
|
||||
onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id),
|
||||
onPressed: () => ref.read(likesControllerProvider).toggle(kind, id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../library/library_providers.dart';
|
||||
|
||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||
@@ -22,67 +28,154 @@ class LikedIds {
|
||||
LikeKind.album => albums.contains(id),
|
||||
LikeKind.track => tracks.contains(id),
|
||||
};
|
||||
|
||||
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
|
||||
}
|
||||
|
||||
class LikedIdsController extends AsyncNotifier<LikedIds> {
|
||||
@override
|
||||
Future<LikedIds> build() async {
|
||||
final api = await ref.watch(likesApiProvider.future);
|
||||
final r = await api.ids();
|
||||
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
|
||||
}
|
||||
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
|
||||
/// SyncController). Reactive — sync writes propagate via watch().
|
||||
/// Empty + online triggers REST cold-cache fallback that re-populates.
|
||||
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedLike, LikedIds>(
|
||||
driftStream: db.select(db.cachedLikes).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(likesApiProvider.future);
|
||||
final fresh = await api.ids();
|
||||
// Need a userId for the composite primary key. The auth controller
|
||||
// exposes the current user.
|
||||
final user = ref.read(authControllerProvider).value;
|
||||
if (user == null) return; // not logged in; skip
|
||||
await db.batch((b) {
|
||||
for (final id in fresh.tracks) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'track',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
for (final id in fresh.albums) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'album',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
for (final id in fresh.artists) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'artist',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
toResult: (rows) => LikedIds(
|
||||
tracks: rows
|
||||
.where((r) => r.entityType == 'track')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
albums: rows
|
||||
.where((r) => r.entityType == 'album')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
artists: rows
|
||||
.where((r) => r.entityType == 'artist')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
),
|
||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||
);
|
||||
});
|
||||
|
||||
/// Mutation controller. Writes optimistically to drift first (so the
|
||||
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
|
||||
/// REST. Rolls back drift on REST failure.
|
||||
class LikesController {
|
||||
LikesController(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> toggle(LikeKind kind, String id) async {
|
||||
final api = await ref.read(likesApiProvider.future);
|
||||
final current = state.value ??
|
||||
const LikedIds(artists: {}, albums: {}, tracks: {});
|
||||
final user = _ref.read(authControllerProvider).value;
|
||||
if (user == null) return;
|
||||
final db = _ref.read(appDbProvider);
|
||||
final entityType = _entityType(kind);
|
||||
|
||||
final isLiked = current.has(kind, id);
|
||||
final optimistic = _flip(current, kind, id);
|
||||
state = AsyncData(optimistic);
|
||||
final existing = await (db.select(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.getSingleOrNull();
|
||||
final wasLiked = existing != null;
|
||||
|
||||
// Optimistic mutation — drift watch() re-emits immediately.
|
||||
if (wasLiked) {
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.go();
|
||||
} else {
|
||||
await db.into(db.cachedLikes).insert(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: entityType,
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isLiked) {
|
||||
final api = await _ref.read(likesApiProvider.future);
|
||||
if (wasLiked) {
|
||||
await api.unlike(kind, id);
|
||||
} else {
|
||||
await api.like(kind, id);
|
||||
}
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
// Rollback drift
|
||||
if (wasLiked) {
|
||||
await db.into(db.cachedLikes).insert(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: entityType,
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
} else {
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.go();
|
||||
}
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
|
||||
Set<String> swap(Set<String> s) {
|
||||
final next = {...s};
|
||||
if (s.contains(id)) {
|
||||
next.remove(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
return switch (kind) {
|
||||
LikeKind.artist => LikedIds(
|
||||
artists: swap(before.artists),
|
||||
albums: before.albums,
|
||||
tracks: before.tracks,
|
||||
),
|
||||
LikeKind.album => LikedIds(
|
||||
artists: before.artists,
|
||||
albums: swap(before.albums),
|
||||
tracks: before.tracks,
|
||||
),
|
||||
LikeKind.track => LikedIds(
|
||||
artists: before.artists,
|
||||
albums: before.albums,
|
||||
tracks: swap(before.tracks),
|
||||
),
|
||||
};
|
||||
}
|
||||
String _entityType(LikeKind kind) => switch (kind) {
|
||||
LikeKind.artist => 'artist',
|
||||
LikeKind.album => 'album',
|
||||
LikeKind.track => 'track',
|
||||
};
|
||||
}
|
||||
|
||||
final likedIdsProvider =
|
||||
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
|
||||
final likesControllerProvider =
|
||||
Provider<LikesController>((ref) => LikesController(ref));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/// One user's report under an aggregated quarantine row.
|
||||
class AdminQuarantineReport {
|
||||
const AdminQuarantineReport({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.reason,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String reason;
|
||||
final String? notes;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminQuarantineReport.fromJson(Map<String, dynamic> j) =>
|
||||
AdminQuarantineReport(
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
reason: j['reason'] as String? ?? '',
|
||||
notes: j['notes'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go.
|
||||
/// One row aggregates all reports against a single track, with
|
||||
/// per-reason counts and the underlying per-user reports nested.
|
||||
class AdminQuarantineItem {
|
||||
const AdminQuarantineItem({
|
||||
required this.trackId,
|
||||
required this.trackTitle,
|
||||
required this.artistName,
|
||||
required this.albumTitle,
|
||||
required this.albumId,
|
||||
this.lidarrAlbumMbid,
|
||||
required this.reportCount,
|
||||
required this.latestAt,
|
||||
required this.reasonCounts,
|
||||
required this.reports,
|
||||
});
|
||||
|
||||
final String trackId;
|
||||
final String trackTitle;
|
||||
final String artistName;
|
||||
final String albumTitle;
|
||||
final String albumId;
|
||||
final String? lidarrAlbumMbid;
|
||||
final int reportCount;
|
||||
final String latestAt;
|
||||
|
||||
/// Map of reason → number of reports citing that reason.
|
||||
final Map<String, int> reasonCounts;
|
||||
|
||||
final List<AdminQuarantineReport> reports;
|
||||
|
||||
/// One-line summary of the most-cited reason. Returns just the top
|
||||
/// reason when only one reason exists, or `top (+N more)` when there
|
||||
/// are additional distinct reasons.
|
||||
String get topReasonSummary {
|
||||
if (reasonCounts.isEmpty) return '';
|
||||
final entries = reasonCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
final top = entries.first.key;
|
||||
return entries.length > 1
|
||||
? '$top (+${entries.length - 1} more)'
|
||||
: top;
|
||||
}
|
||||
|
||||
factory AdminQuarantineItem.fromJson(Map<String, dynamic> j) {
|
||||
final reasonCountsRaw =
|
||||
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
final reportsRaw = (j['reports'] as List?) ?? const [];
|
||||
return AdminQuarantineItem(
|
||||
trackId: j['track_id'] as String? ?? '',
|
||||
trackTitle: j['track_title'] as String? ?? '',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
albumId: j['album_id'] as String? ?? '',
|
||||
lidarrAlbumMbid: j['lidarr_album_mbid'] as String?,
|
||||
reportCount: (j['report_count'] as int?) ?? 0,
|
||||
latestAt: j['latest_at'] as String? ?? '',
|
||||
reasonCounts:
|
||||
reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)),
|
||||
reports: reportsRaw
|
||||
.map((e) =>
|
||||
AdminQuarantineReport.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// Mirrors `requestView` from internal/api/requests.go. Server returns
|
||||
/// these as a flat list (no envelope) from GET /api/admin/requests.
|
||||
///
|
||||
/// Note the requester is exposed only as a UUID (`user_id`) — the
|
||||
/// response does not include the username. The Flutter Requests screen
|
||||
/// joins client-side against the AdminUser list for display.
|
||||
class AdminRequest {
|
||||
const AdminRequest({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.status,
|
||||
required this.kind,
|
||||
required this.artistName,
|
||||
this.albumTitle,
|
||||
this.trackTitle,
|
||||
required this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.notes,
|
||||
required this.importedAlbumCount,
|
||||
required this.importedTrackCount,
|
||||
this.matchedTrackId,
|
||||
this.matchedAlbumId,
|
||||
this.matchedArtistId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
|
||||
/// One of: pending, approved, rejected, completed, failed.
|
||||
final String status;
|
||||
|
||||
/// One of: artist, album, track.
|
||||
final String kind;
|
||||
|
||||
final String artistName;
|
||||
final String? albumTitle;
|
||||
final String? trackTitle;
|
||||
final String requestedAt;
|
||||
final String? decidedAt;
|
||||
final String? notes;
|
||||
final int importedAlbumCount;
|
||||
final int importedTrackCount;
|
||||
|
||||
/// Set when the ingest matched into the local library. The user-side
|
||||
/// "Listen" CTA on a completed request links to whichever id is set
|
||||
/// (most-specific first: track → album → artist).
|
||||
final String? matchedTrackId;
|
||||
final String? matchedAlbumId;
|
||||
final String? matchedArtistId;
|
||||
|
||||
/// Display label depending on the kind of request — what the user
|
||||
/// asked for. For an album request it's the album title; for an
|
||||
/// artist request it's the artist name; etc.
|
||||
String get displayName => switch (kind) {
|
||||
'album' => albumTitle ?? artistName,
|
||||
'track' => trackTitle ?? artistName,
|
||||
_ => artistName,
|
||||
};
|
||||
|
||||
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
|
||||
id: j['id'] as String? ?? '',
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
status: j['status'] as String? ?? 'pending',
|
||||
kind: j['kind'] as String? ?? 'artist',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String?,
|
||||
trackTitle: j['track_title'] as String?,
|
||||
requestedAt: j['requested_at'] as String? ?? '',
|
||||
decidedAt: j['decided_at'] as String?,
|
||||
notes: j['notes'] as String?,
|
||||
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
|
||||
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
|
||||
matchedTrackId: j['matched_track_id'] as String?,
|
||||
matchedAlbumId: j['matched_album_id'] as String?,
|
||||
matchedArtistId: j['matched_artist_id'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct
|
||||
/// from MyProfile because admin endpoints expose `auto_approve_requests`
|
||||
/// and the canonical `created_at` that the user-scoped /me response
|
||||
/// intentionally omits.
|
||||
///
|
||||
/// The list endpoint wraps these in `{"users": [...]}`; the parsing of
|
||||
/// the envelope is done in `AdminUsersApi`, not here.
|
||||
class AdminUser {
|
||||
const AdminUser({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
required this.isAdmin,
|
||||
required this.autoApproveRequests,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final bool isAdmin;
|
||||
final bool autoApproveRequests;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
|
||||
id: j['id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
displayName: j['display_name'] as String?,
|
||||
isAdmin: j['is_admin'] as bool? ?? false,
|
||||
autoApproveRequests: j['auto_approve_requests'] as bool? ?? false,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'track.dart';
|
||||
|
||||
/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits
|
||||
/// `{events, has_more}` rather than the standard `Page<T>` envelope —
|
||||
/// history is timestamp-keyed so total isn't meaningful, only "more
|
||||
/// pages exist below."
|
||||
class HistoryEvent {
|
||||
const HistoryEvent({
|
||||
required this.id,
|
||||
required this.playedAt,
|
||||
required this.track,
|
||||
});
|
||||
|
||||
final String id;
|
||||
/// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" /
|
||||
/// "May 1, 2025") via formatRelative helper.
|
||||
final String playedAt;
|
||||
final TrackRef track;
|
||||
|
||||
factory HistoryEvent.fromJson(Map<String, dynamic> j) => HistoryEvent(
|
||||
id: j['id'] as String? ?? '',
|
||||
playedAt: j['played_at'] as String? ?? '',
|
||||
track: TrackRef.fromJson(
|
||||
(j['track'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class HistoryPage {
|
||||
const HistoryPage({required this.events, required this.hasMore});
|
||||
|
||||
final List<HistoryEvent> events;
|
||||
final bool hasMore;
|
||||
|
||||
factory HistoryPage.fromJson(Map<String, dynamic> j) {
|
||||
final raw = (j['events'] as List?) ?? const [];
|
||||
return HistoryPage(
|
||||
events: raw
|
||||
.map((e) => HistoryEvent.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
hasMore: j['has_more'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list
|
||||
/// endpoint wraps these in `{"invites": [...]}`; the create endpoint
|
||||
/// returns a single bare invite. Envelope parsing is done in
|
||||
/// `AdminInvitesApi`, not here.
|
||||
///
|
||||
/// `invitedBy` is the UUID of the inviting admin (not their username);
|
||||
/// the server doesn't currently denormalize it. Likewise `redeemedBy`.
|
||||
/// TTL is hardcoded server-side at 24h, so admins can't customise the
|
||||
/// expiry — only the optional `note`.
|
||||
class Invite {
|
||||
const Invite({
|
||||
required this.token,
|
||||
required this.invitedBy,
|
||||
this.note,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
this.redeemedAt,
|
||||
this.redeemedBy,
|
||||
});
|
||||
|
||||
final String token;
|
||||
final String invitedBy;
|
||||
final String? note;
|
||||
final String createdAt;
|
||||
final String expiresAt;
|
||||
final String? redeemedAt;
|
||||
final String? redeemedBy;
|
||||
|
||||
bool get isRedeemed => redeemedAt != null;
|
||||
|
||||
factory Invite.fromJson(Map<String, dynamic> j) => Invite(
|
||||
token: j['token'] as String? ?? '',
|
||||
invitedBy: j['invited_by'] as String? ?? '',
|
||||
note: j['note'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
expiresAt: j['expires_at'] as String? ?? '',
|
||||
redeemedAt: j['redeemed_at'] as String?,
|
||||
redeemedBy: j['redeemed_by'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in
|
||||
/// `/api/lidarr/search` results. `in_library` and `requested` let the
|
||||
/// UI gray out rows the user can't act on (already imported / already
|
||||
/// awaiting review).
|
||||
class LidarrSearchResult {
|
||||
const LidarrSearchResult({
|
||||
required this.mbid,
|
||||
required this.name,
|
||||
required this.secondaryText,
|
||||
required this.imageUrl,
|
||||
required this.artistMbid,
|
||||
required this.albumMbid,
|
||||
required this.inLibrary,
|
||||
required this.requested,
|
||||
});
|
||||
|
||||
final String mbid;
|
||||
final String name;
|
||||
final String secondaryText;
|
||||
final String imageUrl;
|
||||
final String artistMbid;
|
||||
final String albumMbid;
|
||||
final bool inLibrary;
|
||||
final bool requested;
|
||||
|
||||
factory LidarrSearchResult.fromJson(Map<String, dynamic> j) =>
|
||||
LidarrSearchResult(
|
||||
mbid: j['mbid'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
secondaryText: j['secondary_text'] as String? ?? '',
|
||||
imageUrl: j['image_url'] as String? ?? '',
|
||||
artistMbid: j['artist_mbid'] as String? ?? '',
|
||||
albumMbid: j['album_mbid'] as String? ?? '',
|
||||
inLibrary: j['in_library'] as bool? ?? false,
|
||||
requested: j['requested'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
enum LidarrRequestKind { artist, album, track }
|
||||
|
||||
extension LidarrRequestKindStr on LidarrRequestKind {
|
||||
String get wire => switch (this) {
|
||||
LidarrRequestKind.artist => 'artist',
|
||||
LidarrRequestKind.album => 'album',
|
||||
LidarrRequestKind.track => 'track',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and
|
||||
/// `email` are nullable — server returns null when the user hasn't
|
||||
/// set them yet (registration only requires a username).
|
||||
class MyProfile {
|
||||
const MyProfile({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
this.email,
|
||||
required this.isAdmin,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final String? email;
|
||||
final bool isAdmin;
|
||||
|
||||
factory MyProfile.fromJson(Map<String, dynamic> j) => MyProfile(
|
||||
id: j['id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
displayName: j['display_name'] as String?,
|
||||
email: j['email'] as String?,
|
||||
isAdmin: j['is_admin'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus.
|
||||
class ListenBrainzStatus {
|
||||
const ListenBrainzStatus({
|
||||
required this.enabled,
|
||||
required this.tokenSet,
|
||||
this.lastScrobbledAt,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
/// True when the user has stored a token (token itself is never read
|
||||
/// back from the server). UI uses this to show "token saved" vs
|
||||
/// "no token" without exposing the value.
|
||||
final bool tokenSet;
|
||||
final String? lastScrobbledAt;
|
||||
|
||||
factory ListenBrainzStatus.fromJson(Map<String, dynamic> j) =>
|
||||
ListenBrainzStatus(
|
||||
enabled: j['enabled'] as bool? ?? false,
|
||||
tokenSet: j['token_set'] as bool? ?? false,
|
||||
lastScrobbledAt: j['last_scrobbled_at'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Mirrors the server's Page[T] envelope used by paged endpoints
|
||||
// (/api/search, /api/library/artists, /api/library/albums,
|
||||
// /api/likes/*). Class is named `Paged<T>` rather than `Page<T>`
|
||||
// to avoid ambiguous imports with Flutter Material's Navigator-2.0
|
||||
// `Page` class.
|
||||
//
|
||||
// Wire shape from internal/api/types.go Page[T]:
|
||||
// { items: T[], total: int, limit: int, offset: int }
|
||||
//
|
||||
// Dart generics can't auto-infer T from JSON, so callers pass an
|
||||
// itemFromJson function alongside the raw map. Parse logic stays in
|
||||
// each entity's own fromJson; this class only handles the envelope.
|
||||
class Paged<T> {
|
||||
const Paged({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.limit,
|
||||
required this.offset,
|
||||
});
|
||||
|
||||
final List<T> items;
|
||||
final int total;
|
||||
final int limit;
|
||||
final int offset;
|
||||
|
||||
factory Paged.fromJson(
|
||||
Map<String, dynamic> j,
|
||||
T Function(Map<String, dynamic>) itemFromJson,
|
||||
) {
|
||||
final raw = (j['items'] as List?) ?? const [];
|
||||
return Paged<T>(
|
||||
items: raw
|
||||
.map((e) => itemFromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
total: (j['total'] as num?)?.toInt() ?? 0,
|
||||
limit: (j['limit'] as num?)?.toInt() ?? 0,
|
||||
offset: (j['offset'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience for endpoints that always return the first page or
|
||||
/// when the caller just wants the items.
|
||||
bool get hasMore => offset + items.length < total;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes.
|
||||
//
|
||||
// Server distinguishes user playlists (created by the caller) from
|
||||
// system playlists (server-generated mixes like for_you / discover).
|
||||
// system_variant is null for user playlists; "for_you" / "discover"
|
||||
// otherwise. Read-only operations work the same; PATCH/POST/DELETE
|
||||
// are gated on user-owned playlists.
|
||||
|
||||
class Playlist {
|
||||
const Playlist({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.isPublic,
|
||||
required this.systemVariant,
|
||||
required this.trackCount,
|
||||
required this.coverUrl,
|
||||
required this.ownerUsername,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
final String name;
|
||||
final String description;
|
||||
final bool isPublic;
|
||||
/// "for_you" / "discover" / null
|
||||
final String? systemVariant;
|
||||
final int trackCount;
|
||||
/// Server-emitted URL to the cover; empty when no tracks yet.
|
||||
final String coverUrl;
|
||||
/// Display name of the owner — for showing other users' public playlists.
|
||||
final String ownerUsername;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
bool get isSystem => systemVariant != null;
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||
id: j['id'] as String,
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
name: j['name'] as String? ?? '',
|
||||
description: j['description'] as String? ?? '',
|
||||
isPublic: j['is_public'] as bool? ?? false,
|
||||
systemVariant: j['system_variant'] as String?,
|
||||
trackCount: (j['track_count'] as num?)?.toInt() ?? 0,
|
||||
coverUrl: j['cover_url'] as String? ?? '',
|
||||
ownerUsername: j['owner_username'] as String? ?? '',
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
updatedAt: j['updated_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Playlist row as returned in PlaylistDetail.tracks. Each row carries
|
||||
/// the track's display fields directly so the client doesn't need a
|
||||
/// separate /api/tracks/{id} fetch per row.
|
||||
///
|
||||
/// track_id is nullable: when the upstream track has been removed from
|
||||
/// the library, the row remains in the playlist with its title/artist
|
||||
/// preserved but track_id=null and stream_url=null. UI should render
|
||||
/// these greyed-out and unplayable.
|
||||
class PlaylistTrack {
|
||||
const PlaylistTrack({
|
||||
required this.position,
|
||||
required this.trackId,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.albumTitle,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
required this.durationSec,
|
||||
required this.streamUrl,
|
||||
});
|
||||
|
||||
final int position;
|
||||
final String? trackId;
|
||||
final String title;
|
||||
final String? albumId;
|
||||
final String albumTitle;
|
||||
final String? artistId;
|
||||
final String artistName;
|
||||
final int durationSec;
|
||||
final String? streamUrl;
|
||||
|
||||
bool get isAvailable => trackId != null;
|
||||
|
||||
factory PlaylistTrack.fromJson(Map<String, dynamic> j) => PlaylistTrack(
|
||||
position: (j['position'] as num?)?.toInt() ?? 0,
|
||||
trackId: j['track_id'] as String?,
|
||||
title: j['title'] as String? ?? '',
|
||||
albumId: j['album_id'] as String?,
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
artistId: j['artist_id'] as String?,
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||
streamUrl: j['stream_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class PlaylistDetail {
|
||||
const PlaylistDetail({required this.playlist, required this.tracks});
|
||||
|
||||
final Playlist playlist;
|
||||
final List<PlaylistTrack> tracks;
|
||||
|
||||
factory PlaylistDetail.fromJson(Map<String, dynamic> j) {
|
||||
final tracksRaw = (j['tracks'] as List?) ?? const [];
|
||||
return PlaylistDetail(
|
||||
playlist: Playlist.fromJson(j),
|
||||
tracks: tracksRaw
|
||||
.map((e) => PlaylistTrack.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row
|
||||
/// per quarantined track owned by the caller. Server returns a flat
|
||||
/// list (no Page envelope) at /api/quarantine/mine.
|
||||
class QuarantineMineRow {
|
||||
const QuarantineMineRow({
|
||||
required this.trackId,
|
||||
required this.reason,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
required this.trackTitle,
|
||||
required this.trackDurationMs,
|
||||
required this.albumId,
|
||||
required this.albumTitle,
|
||||
this.albumCoverArtPath,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
});
|
||||
|
||||
final String trackId;
|
||||
/// One of: bad_rip / wrong_file / wrong_tags / duplicate / other.
|
||||
final String reason;
|
||||
final String? notes;
|
||||
final String createdAt;
|
||||
final String trackTitle;
|
||||
final int trackDurationMs;
|
||||
final String albumId;
|
||||
final String albumTitle;
|
||||
final String? albumCoverArtPath;
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
|
||||
factory QuarantineMineRow.fromJson(Map<String, dynamic> j) =>
|
||||
QuarantineMineRow(
|
||||
trackId: j['track_id'] as String? ?? '',
|
||||
reason: j['reason'] as String? ?? 'other',
|
||||
notes: j['notes'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
trackTitle: j['track_title'] as String? ?? '',
|
||||
trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0,
|
||||
albumId: j['album_id'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
albumCoverArtPath: j['album_cover_art_path'] as String?,
|
||||
artistId: j['artist_id'] as String? ?? '',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'album.dart';
|
||||
import 'artist.dart';
|
||||
import 'page.dart';
|
||||
import 'track.dart';
|
||||
|
||||
/// Mirrors internal/api/search.go SearchResponse — three pages keyed by
|
||||
/// facet, each independently paged. The mobile UI usually only walks
|
||||
/// the first page of each facet (limit 20-50); infinite scroll within a
|
||||
/// facet is a future enhancement, not v1.
|
||||
class SearchResponse {
|
||||
const SearchResponse({
|
||||
required this.artists,
|
||||
required this.albums,
|
||||
required this.tracks,
|
||||
});
|
||||
|
||||
final Paged<ArtistRef> artists;
|
||||
final Paged<AlbumRef> albums;
|
||||
final Paged<TrackRef> tracks;
|
||||
|
||||
factory SearchResponse.fromJson(Map<String, dynamic> j) => SearchResponse(
|
||||
artists: Paged.fromJson(
|
||||
(j['artists'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
ArtistRef.fromJson,
|
||||
),
|
||||
albums: Paged.fromJson(
|
||||
(j['albums'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
AlbumRef.fromJson,
|
||||
),
|
||||
tracks: Paged.fromJson(
|
||||
(j['tracks'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
TrackRef.fromJson,
|
||||
),
|
||||
);
|
||||
|
||||
bool get isEmpty =>
|
||||
artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/// Mirrors `systemPlaylistsStatusResp` from internal/api/me_system_playlists.go.
|
||||
/// Reflects the caller's most recent system_playlist_runs row, or zero
|
||||
/// values when no row exists yet (the user has never had a build attempted).
|
||||
class SystemPlaylistsStatus {
|
||||
const SystemPlaylistsStatus({
|
||||
required this.inFlight,
|
||||
this.lastRunAt,
|
||||
this.lastError,
|
||||
});
|
||||
|
||||
final bool inFlight;
|
||||
final String? lastRunAt;
|
||||
final String? lastError;
|
||||
|
||||
factory SystemPlaylistsStatus.empty() =>
|
||||
const SystemPlaylistsStatus(inFlight: false);
|
||||
|
||||
factory SystemPlaylistsStatus.fromJson(Map<String, dynamic> j) =>
|
||||
SystemPlaylistsStatus(
|
||||
inFlight: j['in_flight'] as bool? ?? false,
|
||||
lastRunAt: j['last_run_at'] as String?,
|
||||
lastError: j['last_error'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Caches album cover bytes to disk so MediaItem.artUri can point at a
|
||||
/// file:// URI. Android's MediaSession framework fetches artUri itself
|
||||
/// and doesn't carry our Bearer header, so we pre-fetch via the
|
||||
/// authenticated dio and hand the system a local file path instead.
|
||||
///
|
||||
/// Cache layout: `{applicationCacheDirectory}/album_covers/{albumId}.jpg`.
|
||||
/// No explicit eviction — covers are tiny and OS clears app cache when
|
||||
/// space is tight.
|
||||
class AlbumCoverCache {
|
||||
AlbumCoverCache({
|
||||
required Future<Dio> Function() dioFactory,
|
||||
Future<Directory> Function()? cacheDirFactory,
|
||||
}) : _dioFactory = dioFactory,
|
||||
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
|
||||
|
||||
final Future<Dio> Function() _dioFactory;
|
||||
final Future<Directory> Function() _cacheDirFactory;
|
||||
|
||||
/// In-flight requests keyed by albumId so concurrent callers for the
|
||||
/// same album dedupe to one fetch.
|
||||
final Map<String, Future<String?>> _inflight = {};
|
||||
|
||||
/// Returns local file path to the album cover, or null on any
|
||||
/// failure (network, 4xx/5xx, disk full, empty albumId).
|
||||
Future<String?> getOrFetch(String albumId) {
|
||||
if (albumId.isEmpty) return Future.value(null);
|
||||
final existing = _inflight[albumId];
|
||||
if (existing != null) return existing;
|
||||
final fut = _doFetch(albumId);
|
||||
_inflight[albumId] = fut;
|
||||
fut.whenComplete(() => _inflight.remove(albumId));
|
||||
return fut;
|
||||
}
|
||||
|
||||
Future<String?> _doFetch(String albumId) async {
|
||||
try {
|
||||
final dir = await _cacheDirFactory();
|
||||
final coversDir = Directory('${dir.path}/album_covers');
|
||||
await coversDir.create(recursive: true);
|
||||
final filePath = '${coversDir.path}/$albumId.jpg';
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) return filePath;
|
||||
|
||||
final dio = await _dioFactory();
|
||||
final r = await dio.get<List<int>>(
|
||||
'/api/albums/$albumId/cover',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final bytes = r.data;
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return filePath;
|
||||
} catch (e) {
|
||||
debugPrint('AlbumCoverCache: fetch failed for $albumId: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,345 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../models/track.dart';
|
||||
import 'album_cover_cache.dart';
|
||||
|
||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
MinstrelAudioHandler() {
|
||||
_player.playbackEventStream.listen(_broadcastState);
|
||||
_player.playbackEventStream.listen(
|
||||
_broadcastState,
|
||||
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||
// failure, network drop) here. Without an error sink, the
|
||||
// player just goes quiet — exactly the "starts then stops"
|
||||
// symptom we hit.
|
||||
onError: (Object e, StackTrace st) {
|
||||
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||
},
|
||||
);
|
||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
||||
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||
_player.loopModeStream.listen((_) => _broadcastState(null));
|
||||
// Watch buffered-position so we can register stream-cached files
|
||||
// in the audio cache index once they're fully downloaded. Without
|
||||
// this, files written by LockCachingAudioSource never appear in
|
||||
// the index and the eviction loop can't reclaim them.
|
||||
_player.bufferedPositionStream
|
||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||
}
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
String _baseUrl = '';
|
||||
String? _token;
|
||||
AlbumCoverCache? _coverCache;
|
||||
AudioCacheManager? _audioCacheManager;
|
||||
|
||||
void configure({required String baseUrl, required String? token}) {
|
||||
/// Trackers to dedupe registration — once we've inserted an index row
|
||||
/// for a trackId, don't repeat the work on every buffered-position
|
||||
/// emit. Cleared on dispose only; surviving across queue rebuilds is
|
||||
/// fine because the index is itself the source of truth.
|
||||
final Set<String> _streamCacheRegistered = {};
|
||||
|
||||
/// Cached on first use so we don't hit the platform channel every
|
||||
/// time the buffered-position stream emits (~200ms cadence).
|
||||
String? _cacheDirPath;
|
||||
|
||||
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||
/// currentIndex (it tracks the actively-playing source through
|
||||
/// list mutations), and the resulting _onCurrentIndexChanged
|
||||
/// callbacks would push the wrong MediaItem onto the stream
|
||||
/// (queue.value[shifted_idx] != actively-playing track). When
|
||||
/// the fill completes, currentIndex == initialIndex, mediaItem
|
||||
/// is already correct, and we re-enable normal listener behavior.
|
||||
bool _suppressIndexUpdates = false;
|
||||
|
||||
/// Increments on every setQueueFromTracks call. The background
|
||||
/// fill task captures the value at start and bails if the live
|
||||
/// counter has moved on — without this, a stale fill will keep
|
||||
/// addAudioSource'ing tracks from the previous playlist into the
|
||||
/// new queue, leaving the player "locked" to a corrupted state.
|
||||
int _queueGeneration = 0;
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
double get volume => _player.volume;
|
||||
|
||||
/// Position stream for UI subscribers. just_audio emits roughly every
|
||||
/// 200ms while playing, which is what makes the seek bar advance
|
||||
/// smoothly. PlaybackState.updatePosition only changes on event
|
||||
/// transitions (play/pause/buffer), so it's too coarse for live
|
||||
/// scrubbing UI.
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
Duration get position => _player.position;
|
||||
|
||||
void configure({
|
||||
required String baseUrl,
|
||||
required String? token,
|
||||
AlbumCoverCache? coverCache,
|
||||
AudioCacheManager? audioCacheManager,
|
||||
}) {
|
||||
_baseUrl = baseUrl;
|
||||
_token = token;
|
||||
if (coverCache != null) _coverCache = coverCache;
|
||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||
}
|
||||
|
||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
||||
|
||||
// Bump the generation FIRST. Any in-flight _fillRemainingSources
|
||||
// from a previous play will see the mismatch on its next gen
|
||||
// check and stop calling player mutations — important so a stale
|
||||
// fill doesn't append old-playlist tracks into the new queue.
|
||||
final myGen = ++_queueGeneration;
|
||||
// Reset suppress flag in case a prior backward-fill bailed on
|
||||
// gen check before reaching its `finally`.
|
||||
_suppressIndexUpdates = false;
|
||||
|
||||
// Populate the visible queue + current mediaItem immediately so
|
||||
// the player UI reflects the user's tap before any source has
|
||||
// been built. Source list at the just_audio layer fills in
|
||||
// asynchronously below.
|
||||
final items = tracks.map(_toMediaItem).toList();
|
||||
queue.add(items);
|
||||
if (items.isNotEmpty) {
|
||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||
mediaItem.add(items[clampedInitial]);
|
||||
|
||||
// Fast path: build only the initial source so the player can
|
||||
// start. Remaining sources stream in via _fillRemainingSources()
|
||||
// in the background — addAudioSource for next/auto-advance
|
||||
// tracks, insertAudioSource for skipPrev tracks.
|
||||
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
||||
if (myGen != _queueGeneration) {
|
||||
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
||||
return;
|
||||
}
|
||||
|
||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||
final sources = tracks.map((t) {
|
||||
final url = t.streamUrl.isNotEmpty
|
||||
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
|
||||
? t.streamUrl
|
||||
: '$_baseUrl${t.streamUrl}')
|
||||
: '$_baseUrl/api/tracks/${t.id}/stream';
|
||||
return AudioSource.uri(Uri.parse(url), headers: headers);
|
||||
}).toList();
|
||||
await _player.setAudioSources([initial], initialIndex: 0);
|
||||
|
||||
await _player.setAudioSource(
|
||||
ConcatenatingAudioSource(children: sources),
|
||||
initialIndex: initialIndex,
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||
}
|
||||
|
||||
/// Background fill of the rest of the just_audio source list after
|
||||
/// the initial source is playing. Forward direction first (most
|
||||
/// common skipNext target). Backward inserts shift the player's
|
||||
/// currentIndex; we suppress _onCurrentIndexChanged side effects
|
||||
/// for those so the mediaItem stream doesn't bounce to the wrong
|
||||
/// queue entry.
|
||||
///
|
||||
/// `gen` is the queue generation captured when this fill started.
|
||||
/// Every loop iteration checks against _queueGeneration; if a
|
||||
/// newer setQueueFromTracks has run (user tapped play on something
|
||||
/// else), bail immediately so we don't pollute the new queue with
|
||||
/// addAudioSource calls from this stale fill.
|
||||
Future<void> _fillRemainingSources(
|
||||
List<TrackRef> tracks, int initialIndex, int gen) async {
|
||||
for (var i = initialIndex + 1; i < tracks.length; i++) {
|
||||
if (gen != _queueGeneration) return;
|
||||
try {
|
||||
final src = await _buildAudioSource(tracks[i]);
|
||||
if (gen != _queueGeneration) return;
|
||||
await _player.addAudioSource(src);
|
||||
} catch (e) {
|
||||
debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
|
||||
}
|
||||
}
|
||||
if (initialIndex > 0) {
|
||||
_suppressIndexUpdates = true;
|
||||
try {
|
||||
for (var i = 0; i < initialIndex; i++) {
|
||||
if (gen != _queueGeneration) return;
|
||||
final src = await _buildAudioSource(tracks[i]);
|
||||
if (gen != _queueGeneration) return;
|
||||
await _player.insertAudioSource(i, src);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('audio_handler: backward fill failed: $e');
|
||||
} finally {
|
||||
// Only release the flag if we're still the active gen — a
|
||||
// newer setQueueFromTracks already reset it for itself.
|
||||
if (gen == _queueGeneration) _suppressIndexUpdates = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _resolveStreamUrl(TrackRef t) {
|
||||
if (t.streamUrl.isEmpty) {
|
||||
return '$_baseUrl/api/tracks/${t.id}/stream';
|
||||
}
|
||||
final parsed = Uri.tryParse(t.streamUrl);
|
||||
if (parsed != null && parsed.hasScheme) {
|
||||
return t.streamUrl;
|
||||
}
|
||||
return '$_baseUrl${t.streamUrl}';
|
||||
}
|
||||
|
||||
/// Builds an AudioSource for a track. Cache-aware:
|
||||
/// 1. If the track is fully cached on disk, returns a file:// source.
|
||||
/// 2. Else returns a LockCachingAudioSource that streams + caches as
|
||||
/// it plays (subsequent plays will hit the cache).
|
||||
///
|
||||
/// Without an audio cache manager configured (e.g. in older code paths
|
||||
/// that pre-date #357), falls back to a plain network AudioSource.uri.
|
||||
Future<AudioSource> _buildAudioSource(TrackRef t) async {
|
||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||
final mgr = _audioCacheManager;
|
||||
|
||||
// 1. Cache hit: play from disk, no headers needed.
|
||||
if (mgr != null) {
|
||||
final path = await mgr.pathFor(t.id);
|
||||
if (path != null) {
|
||||
return AudioSource.uri(Uri.file(path));
|
||||
}
|
||||
}
|
||||
|
||||
final url = _resolveStreamUrl(t);
|
||||
final parsed = Uri.parse(url);
|
||||
if (!parsed.hasScheme || parsed.host.isEmpty) {
|
||||
throw StateError(
|
||||
'audio_handler: refused to play scheme-less URL "$url" '
|
||||
'(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", '
|
||||
'track.id="${t.id}"). configure() must be called with a '
|
||||
'non-empty baseUrl before setQueueFromTracks().',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Cache miss WITH manager: stream + cache-as-you-play. Future
|
||||
// plays of this track will hit the cache. If the cache write
|
||||
// completes, we don't currently register an index row — that would
|
||||
// require a download-complete hook from just_audio that's not
|
||||
// exposed cleanly. Acceptable for v1: prefetcher / explicit
|
||||
// pin / Download buttons cover the index path; LockCaching handles
|
||||
// the network optimization.
|
||||
if (mgr != null) {
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
||||
// ignore: experimental_member_use
|
||||
return LockCachingAudioSource(parsed,
|
||||
headers: headers, cacheFile: cacheFile);
|
||||
}
|
||||
|
||||
// 3. No manager configured: plain network source (legacy path).
|
||||
return AudioSource.uri(parsed, headers: headers);
|
||||
}
|
||||
|
||||
/// Inserts [track] right after the currently-playing item so it plays
|
||||
/// next. If nothing is playing, appends to the end.
|
||||
Future<void> playNext(TrackRef track) async {
|
||||
final source = await _buildAudioSource(track);
|
||||
final item = _toMediaItem(track);
|
||||
final currentIdx = _player.currentIndex;
|
||||
final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1;
|
||||
await _player.insertAudioSource(insertAt, source);
|
||||
final current = queue.value;
|
||||
queue.add([
|
||||
...current.sublist(0, insertAt),
|
||||
item,
|
||||
...current.sublist(insertAt),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Appends [track] to the end of the queue.
|
||||
Future<void> enqueue(TrackRef track) async {
|
||||
final source = await _buildAudioSource(track);
|
||||
final item = _toMediaItem(track);
|
||||
await _player.addAudioSource(source);
|
||||
queue.add([...queue.value, item]);
|
||||
}
|
||||
|
||||
MediaItem _toMediaItem(TrackRef t) {
|
||||
// Stash album_id + artist_id in extras so widgets reconstructing
|
||||
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
||||
// "Go to album") have the IDs they need to navigate. Earlier code
|
||||
// only carried album_id which left "Go to artist" pushing
|
||||
// /artists/ (empty id, route 404).
|
||||
final extras = <String, dynamic>{};
|
||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||
return MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
extras: extras.isEmpty ? null : extras,
|
||||
);
|
||||
}
|
||||
|
||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
);
|
||||
/// Once a track is fully buffered (LockCaching has written the whole
|
||||
/// file to disk), insert an audio_cache_index row so the file shows
|
||||
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
|
||||
/// cache manager isn't configured, no current track, the file isn't
|
||||
/// fully buffered yet, the on-disk file is missing, or we already
|
||||
/// registered this trackId during this subscription.
|
||||
Future<void> _maybeRegisterStreamCache() async {
|
||||
final mgr = _audioCacheManager;
|
||||
if (mgr == null) return;
|
||||
final current = mediaItem.value;
|
||||
if (current == null) return;
|
||||
final trackId = current.id;
|
||||
if (_streamCacheRegistered.contains(trackId)) return;
|
||||
|
||||
final dur = _player.duration;
|
||||
if (dur == null) return;
|
||||
final buf = _player.bufferedPosition;
|
||||
// 200ms slack for header bytes / encoding rounding.
|
||||
if (buf < dur - const Duration(milliseconds: 200)) return;
|
||||
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return;
|
||||
final size = await file.length();
|
||||
if (size <= 0) return;
|
||||
|
||||
_streamCacheRegistered.add(trackId);
|
||||
await mgr.registerStreamCache(trackId, path, size);
|
||||
}
|
||||
|
||||
void _onCurrentIndexChanged(int? idx) {
|
||||
if (idx == null) return;
|
||||
if (_suppressIndexUpdates) return;
|
||||
// Push the new track's MediaItem onto the mediaItem stream so
|
||||
// the player UI rebuilds with the new title/artist/album/cover.
|
||||
// Without this, the bar and full player stayed pinned to whichever
|
||||
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
||||
// of skip/auto-advance.
|
||||
final items = queue.value;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
mediaItem.add(items[idx]);
|
||||
}
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
}
|
||||
|
||||
/// Async-fetches the cover for whichever item is currently active and
|
||||
/// pushes a MediaItem update with artUri set. No-op if no cache is
|
||||
/// configured, no current item, the item has no album_id in extras,
|
||||
/// or the fetch returns null.
|
||||
Future<void> _loadArtForCurrentItem() async {
|
||||
final cache = _coverCache;
|
||||
if (cache == null) return;
|
||||
final current = mediaItem.value;
|
||||
if (current == null) return;
|
||||
final albumId = current.extras?['album_id'] as String?;
|
||||
if (albumId == null || albumId.isEmpty) return;
|
||||
if (current.artUri != null) return; // already set
|
||||
final path = await cache.getOrFetch(albumId);
|
||||
if (path == null) return;
|
||||
// Discard if the user advanced to another track while we waited.
|
||||
if (mediaItem.value?.id != current.id) return;
|
||||
mediaItem.add(current.copyWith(artUri: Uri.file(path)));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
@@ -63,7 +356,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
@override
|
||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||
|
||||
void _broadcastState(PlaybackEvent event) {
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await _player
|
||||
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
||||
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
final loop = switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => LoopMode.off,
|
||||
AudioServiceRepeatMode.one => LoopMode.one,
|
||||
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
||||
};
|
||||
await _player.setLoopMode(loop);
|
||||
}
|
||||
|
||||
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
||||
/// page audio to system volume — this is the in-app slider for parity
|
||||
/// with desktop/web; on mobile the system volume is the real control.
|
||||
Future<void> setVolume(double v) async {
|
||||
await _player.setVolume(v.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
// _broadcastState accepts a nullable event because the shuffle/repeat
|
||||
// listeners don't have one — we just want to re-emit PlaybackState
|
||||
// with up-to-date shuffleMode/repeatMode fields.
|
||||
void _broadcastState(PlaybackEvent? event) {
|
||||
final playing = _player.playing;
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [
|
||||
@@ -71,7 +391,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {MediaAction.seek},
|
||||
// androidCompactActionIndices tells the system which controls
|
||||
// appear in the collapsed/lock-screen view. Without this, some
|
||||
// Android versions render the player without working buttons.
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
// systemActions enumerates which actions the system can invoke
|
||||
// on us — without play/pause/skip in here, taps on lock-screen
|
||||
// controls don't route back to the handler on Android 13+.
|
||||
systemActions: const {
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.skipToNext,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.seek,
|
||||
},
|
||||
processingState: switch (_player.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
@@ -83,7 +416,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
queueIndex: event?.currentIndex ?? _player.currentIndex,
|
||||
shuffleMode: _player.shuffleModeEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: switch (_player.loopMode) {
|
||||
LoopMode.off => AudioServiceRepeatMode.none,
|
||||
LoopMode.one => AudioServiceRepeatMode.one,
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,415 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class NowPlayingScreen extends ConsumerWidget {
|
||||
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||
/// transition (see routing.dart). Dismisses three ways:
|
||||
///
|
||||
/// - System back / leading button → Navigator.pop
|
||||
/// - Vertical drag down past threshold → Navigator.pop (matches the
|
||||
/// slide-down animation users expect from a "pull down to close"
|
||||
/// sheet)
|
||||
/// - Tap of the mini player ascends here; reverse motion descends back.
|
||||
class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
||||
}
|
||||
|
||||
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// Cumulative drag distance used to decide if a vertical drag should
|
||||
// dismiss. Reset on each drag start.
|
||||
double _dragOffset = 0;
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
_dragOffset += d.delta.dy;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
// Pop when either the user has dragged > 80px down OR flicked
|
||||
// downward at speed (>500 px/s). Either should feel responsive
|
||||
// without dismissing on accidental drags.
|
||||
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
|
||||
if (_dragOffset > 80 || flicked) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).valueOrNull;
|
||||
final playback = ref.watch(playbackStateProvider).valueOrNull;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
|
||||
if (media == null) {
|
||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||
}
|
||||
|
||||
final pos = playback?.updatePosition ?? Duration.zero;
|
||||
// Use positionProvider (just_audio's positionStream, ~200ms) for
|
||||
// the seek bar so it scrubs live; PlaybackState.updatePosition
|
||||
// only fires on state transitions and would leave the bar frozen
|
||||
// between them.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(children: [
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 280, height: 280, color: fs.slate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(media.title, style: TextStyle(color: fs.parchment, fontSize: 22)),
|
||||
Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)),
|
||||
const SizedBox(height: 16),
|
||||
Slider(
|
||||
activeColor: fs.accent,
|
||||
min: 0,
|
||||
max: dur.inMilliseconds.toDouble().clamp(1, double.infinity),
|
||||
value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||
// and the seek slider are still tappable since gesture arena
|
||||
// gives priority to their child gesture detectors.
|
||||
body: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(fs: fs),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
_AlbumArt(media: media, albumId: albumId, fs: fs),
|
||||
const SizedBox(height: 28),
|
||||
_TitleRow(media: media, fs: fs),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((media.album ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
media.album!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
_SecondaryControls(
|
||||
fs: fs,
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 24),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
const Spacer(),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.fs});
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumArt extends StatelessWidget {
|
||||
const _AlbumArt({required this.media, required this.albumId, required this.fs});
|
||||
final MediaItem media;
|
||||
final String albumId;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pick the best available source. AlbumCoverCache writes a file://
|
||||
// URI to media.artUri once the cover lands on disk; before then,
|
||||
// fall back to fetching via ServerImage from /api/albums/<id>/cover
|
||||
// (which carries the auth header and falls back gracefully on
|
||||
// failure).
|
||||
Widget cover;
|
||||
final artUri = media.artUri;
|
||||
if (artUri != null && artUri.isScheme('file')) {
|
||||
cover = Image(
|
||||
image: FileImage(File.fromUri(artUri)),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: fs.slate),
|
||||
);
|
||||
} else if (albumId.isNotEmpty) {
|
||||
cover = ServerImage(
|
||||
url: '/api/albums/$albumId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
} else {
|
||||
cover = Container(color: fs.slate);
|
||||
}
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleRow extends StatelessWidget {
|
||||
const _TitleRow({required this.media, required this.fs});
|
||||
final MediaItem media;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Title-only — like + kebab moved into _SecondaryControls above
|
||||
// the seek bar so they share the same row as shuffle/repeat/queue.
|
||||
return Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends StatelessWidget {
|
||||
const _SeekRow({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Column(children: [
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryControls extends StatelessWidget {
|
||||
const _PrimaryControls({
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
required this.isPlaying,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
final bool isPlaying;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 72,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action row sitting just above the seek bar. Holds shuffle / repeat
|
||||
/// / queue plus the like + kebab that used to live in the title row,
|
||||
/// so the title can sit truly centered above and this row carries
|
||||
/// every per-track action in one place.
|
||||
class _SecondaryControls extends StatelessWidget {
|
||||
const _SecondaryControls({
|
||||
required this.fs,
|
||||
required this.actions,
|
||||
required this.shuffleOn,
|
||||
required this.repeatMode,
|
||||
required this.media,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final PlayerActions actions;
|
||||
final bool shuffleOn;
|
||||
final AudioServiceRepeatMode repeatMode;
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
),
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
// /now-playing lives outside the ShellRoute. Pushing
|
||||
// /artists/:id or /albums/:id (both shell-children) on top
|
||||
// of it would make go_router try to mount a duplicate
|
||||
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
|
||||
// we hit before with /queue). Await our own pop fully
|
||||
// before pushing the destination so go_router never sees
|
||||
// both pages active in the same frame.
|
||||
onNavigate: (path) async {
|
||||
await Navigator.of(context).maybePop();
|
||||
if (context.mounted) GoRouter.of(context).push(path);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,295 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||
/// view only — the heavyweight shuffle/repeat/queue/volume controls
|
||||
/// live in NowPlayingScreen, accessible by tap or swipe-up.
|
||||
///
|
||||
/// ┌─────────────────────────────────────┬──────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
||||
/// │ Artist │ │
|
||||
/// ├─────────────────────────────────────────────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └─────────────────────────────────────────────────┘
|
||||
///
|
||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||
/// the drag-down dismissal on the full screen.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
|
||||
final playback = ref.watch(playbackStateProvider).valueOrNull;
|
||||
if (mediaItem == null) return const SizedBox.shrink();
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
if (media == null) return const SizedBox.shrink();
|
||||
|
||||
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||
// only updates on event transitions and would leave it frozen.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
|
||||
return Material(
|
||||
color: fs.iron,
|
||||
child: InkWell(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/now-playing'),
|
||||
// Swipe up anywhere on the bar to expand into the full player.
|
||||
// We only act on a clear upward flick (>200 px/s) so a slow
|
||||
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
||||
onVerticalDragEnd: (d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -200) context.push('/now-playing');
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(children: [
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _TrackInfo(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SeekRow(position: pos, duration: dur),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfo extends StatelessWidget {
|
||||
const _TrackInfo({required this.media});
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
return Row(
|
||||
// Default centering vertically aligns the like/kebab buttons
|
||||
// against the album art (48dp) — visually they span the title +
|
||||
// artist block instead of sitting on the title baseline.
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (media.artUri != null)
|
||||
Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
||||
// would render as a tiny inset. Cover stretches/crops to
|
||||
// fill the box uniformly.
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
)
|
||||
else
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Like + kebab as siblings to the title/artist column rather
|
||||
// than nested inside the title row. Width stays 32dp each so
|
||||
// horizontal footprint matches what we had; height stretches
|
||||
// to the row's full 48dp so the icons sit at vertical center
|
||||
// against the title+artist block.
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: LikeButton(
|
||||
kind: LikeKind.track,
|
||||
id: media.id,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayControls extends StatelessWidget {
|
||||
const _PlayControls({required this.playback, required this.ref});
|
||||
final PlaybackState? playback;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 32,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends ConsumerWidget {
|
||||
const _SeekRow({required this.position, required this.duration});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final maxMs =
|
||||
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(position),
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(duration),
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
||||
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
||||
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
||||
/// audio_handler stashed at queue-build time.
|
||||
TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
// artist_id is stashed in extras by audio_handler — without it
|
||||
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/radio.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/track.dart';
|
||||
import 'album_cover_cache.dart';
|
||||
import 'audio_handler.dart';
|
||||
|
||||
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
||||
throw UnimplementedError('overridden in main()');
|
||||
});
|
||||
|
||||
final albumCoverCacheProvider = Provider<AlbumCoverCache>((ref) {
|
||||
return AlbumCoverCache(
|
||||
dioFactory: () => ref.read(dioProvider.future),
|
||||
);
|
||||
});
|
||||
|
||||
final playbackStateProvider = StreamProvider<PlaybackState>(
|
||||
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
||||
);
|
||||
@@ -21,6 +31,23 @@ final queueProvider = StreamProvider<List<MediaItem>>(
|
||||
(ref) => ref.watch(audioHandlerProvider).queue,
|
||||
);
|
||||
|
||||
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
|
||||
/// modify via PlayerActions.setVolume(). Surfaced separately from
|
||||
/// PlaybackState because audio_service's PlaybackState doesn't carry
|
||||
/// volume — it's a player-side concern.
|
||||
final volumeProvider = StreamProvider<double>(
|
||||
(ref) => ref.watch(audioHandlerProvider).volumeStream,
|
||||
);
|
||||
|
||||
/// Live playback position. just_audio emits at ~200ms cadence so seek
|
||||
/// bars driven by this provider scrub smoothly. Don't use
|
||||
/// PlaybackState.updatePosition for that — it only changes on state
|
||||
/// transitions (play/pause/buffer/seek) and the bar would appear
|
||||
/// frozen between events.
|
||||
final positionProvider = StreamProvider<Duration>(
|
||||
(ref) => ref.watch(audioHandlerProvider).positionStream,
|
||||
);
|
||||
|
||||
class PlayerActions {
|
||||
PlayerActions(this._ref);
|
||||
final Ref _ref;
|
||||
@@ -28,10 +55,63 @@ class PlayerActions {
|
||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token);
|
||||
final cache = _ref.read(albumCoverCacheProvider);
|
||||
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||
final h = _ref.read(audioHandlerProvider)
|
||||
..configure(
|
||||
baseUrl: url ?? '',
|
||||
token: token,
|
||||
coverCache: cache,
|
||||
audioCacheManager: audioCache,
|
||||
);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||
await h.play();
|
||||
}
|
||||
|
||||
Future<void> playNext(TrackRef track) async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.playNext(track);
|
||||
}
|
||||
|
||||
Future<void> enqueue(TrackRef track) async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.enqueue(track);
|
||||
}
|
||||
|
||||
/// Toggles shuffle on/off. Pre-existing audio_service convention.
|
||||
Future<void> toggleShuffle() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = state.shuffleMode == AudioServiceShuffleMode.none
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none;
|
||||
await h.setShuffleMode(next);
|
||||
}
|
||||
|
||||
/// Cycles repeat mode: none → all → one → none.
|
||||
Future<void> cycleRepeat() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = switch (state.repeatMode) {
|
||||
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
|
||||
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
|
||||
_ => AudioServiceRepeatMode.none,
|
||||
};
|
||||
await h.setRepeatMode(next);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double v) async {
|
||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||
}
|
||||
|
||||
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
||||
/// returned track list (seed at index 0 + recommended picks).
|
||||
Future<void> startRadio(String trackId) async {
|
||||
final dio = await _ref.read(dioProvider.future);
|
||||
final tracks = await RadioApi(dio).seedTrack(trackId);
|
||||
if (tracks.isEmpty) return;
|
||||
await playTracks(tracks);
|
||||
}
|
||||
}
|
||||
|
||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class QueueScreen extends ConsumerWidget {
|
||||
const QueueScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final queue = ref.watch(queueProvider).value ?? const [];
|
||||
final current = ref.watch(mediaItemProvider).value;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Queue', style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
body: queue.isEmpty
|
||||
? Center(
|
||||
child: Text('Queue is empty.', style: TextStyle(color: fs.ash)),
|
||||
)
|
||||
: ListView.separated(
|
||||
itemCount: queue.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) => _QueueRow(
|
||||
item: queue[i],
|
||||
index: i,
|
||||
isCurrent: current?.id == queue[i].id,
|
||||
onTap: () async {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
await h.skipToQueueItem(i);
|
||||
await h.play();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QueueRow extends StatelessWidget {
|
||||
const _QueueRow({
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.isCurrent,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final MediaItem item;
|
||||
final int index;
|
||||
final bool isCurrent;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final dur = item.duration;
|
||||
String? durLabel;
|
||||
if (dur != null) {
|
||||
final m = (dur.inSeconds ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (dur.inSeconds % 60).toString().padLeft(2, '0');
|
||||
durLabel = '$m:$s';
|
||||
}
|
||||
return InkWell(
|
||||
onTap: isCurrent ? null : onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: isCurrent
|
||||
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||
: null,
|
||||
color: isCurrent ? fs.iron : null,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
if (isCurrent)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(Icons.graphic_eq, color: fs.accent, size: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: isCurrent ? fs.accent : fs.parchment,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${item.artist ?? ''} · ${item.album ?? ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (durLabel != null)
|
||||
Text(durLabel, style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
class PlaylistDetailScreen extends ConsumerWidget {
|
||||
const PlaylistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional Playlist passed via go_router extra so the header
|
||||
/// (title, cover, track count, play/download CTAs) can render
|
||||
/// before the full detail fetch resolves. Same pattern as album
|
||||
/// + artist nav hydration.
|
||||
final Playlist? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final detail = ref.watch(playlistDetailProvider(id));
|
||||
|
||||
// Resolve the best playlist info available for the AppBar title.
|
||||
final livePlaylist = detail.value?.playlist;
|
||||
final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty)
|
||||
? livePlaylist.name
|
||||
: (seed?.name ?? '');
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: headerName.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
headerName,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
// Loading from a seed: render the header immediately + a
|
||||
// small inline "loading tracks" hint, instead of an opaque
|
||||
// full-screen spinner. The body fills in when tracks arrive.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _SeedBody(playlist: seed!),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (d) => _Body(detail: d),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading-state body: renders just the header from the seed
|
||||
/// playlist + a "loading tracks…" placeholder. CTA buttons are
|
||||
/// hidden until the real track list arrives (they need playable
|
||||
/// refs to do anything useful).
|
||||
class _SeedBody extends StatelessWidget {
|
||||
const _SeedBody({required this.playlist});
|
||||
final Playlist playlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (playlist.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
playlist.description,
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends ConsumerWidget {
|
||||
const _Body({required this.detail});
|
||||
final PlaylistDetail detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final tracks = detail.tracks;
|
||||
final playable = tracks.where((t) => t.isAvailable).toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
|
||||
child: ListView.builder(
|
||||
// Header + (track rows | empty hint).
|
||||
itemCount: tracks.isEmpty ? 2 : tracks.length + 1,
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == 0) return _Header(detail: detail, playable: playable);
|
||||
if (tracks.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No tracks yet. Add some via the "Add to playlist…" entry on any track row.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final t = tracks[i - 1];
|
||||
return _PlaylistTrackRow(
|
||||
row: t,
|
||||
onTap: t.isAvailable
|
||||
? () {
|
||||
final ref = ProviderScope.containerOf(ctx);
|
||||
final liveTrack = _toTrackRef(t);
|
||||
final playableRefs =
|
||||
playable.map(_toTrackRef).toList(growable: false);
|
||||
final startIdx = playable.indexWhere((p) => p.trackId == t.trackId);
|
||||
ref.read(playerActionsProvider).playTracks(
|
||||
playableRefs,
|
||||
initialIndex: startIdx >= 0 ? startIdx : 0,
|
||||
);
|
||||
// Keep liveTrack referenced to avoid an unused-variable
|
||||
// warning while we leave hooks for menu wiring later.
|
||||
assert(liveTrack.id == t.trackId);
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TrackRef _toTrackRef(PlaylistTrack t) => TrackRef(
|
||||
id: t.trackId ?? '',
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
albumTitle: t.albumTitle,
|
||||
artistId: t.artistId ?? '',
|
||||
artistName: t.artistName,
|
||||
durationSec: t.durationSec,
|
||||
streamUrl: t.streamUrl ?? '',
|
||||
);
|
||||
|
||||
class _Header extends ConsumerWidget {
|
||||
const _Header({required this.detail, required this.playable});
|
||||
final PlaylistDetail detail;
|
||||
final List<PlaylistTrack> playable;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final p = detail.playlist;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (p.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
p.description,
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Row(children: [
|
||||
Text(
|
||||
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
if (playable.isNotEmpty) ...[
|
||||
OutlinedButton.icon(
|
||||
key: const Key('download_playlist_button'),
|
||||
onPressed: () {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
for (final t in playable) {
|
||||
final id = t.trackId ?? '';
|
||||
if (id.isEmpty) continue;
|
||||
// Fire-and-forget; downloads happen in background.
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(id, source: CacheSource.autoPlaylist);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('Download'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||
ref.read(playerActionsProvider).playTracks(refs);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTrackRow extends StatelessWidget {
|
||||
const _PlaylistTrackRow({required this.row, required this.onTap});
|
||||
final PlaylistTrack row;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
|
||||
final color = row.isAvailable ? fs.parchment : fs.ash;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
decoration: row.isAvailable
|
||||
? null
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (row.trackId != null)
|
||||
TrackActionsButton(track: _toTrackRef(row)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/playlist.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
class PlaylistsListScreen extends ConsumerWidget {
|
||||
const PlaylistsListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// "all" returns user-created + system mixes. Most useful default
|
||||
// on mobile where the user wants to see for-you/discover alongside
|
||||
// their own playlists in one tap.
|
||||
final list = ref.watch(playlistsListProvider('all'));
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/playlists')],
|
||||
),
|
||||
body: list.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (lists) {
|
||||
final items = lists.all;
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No playlists yet.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(playlistsListProvider('all').future),
|
||||
child: ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(height: 1, color: fs.iron),
|
||||
itemBuilder: (ctx, i) {
|
||||
final p = items[i];
|
||||
return _PlaylistTile(
|
||||
playlist: p,
|
||||
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTile extends StatelessWidget {
|
||||
const _PlaylistTile({required this.playlist, required this.onTap});
|
||||
final Playlist playlist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 15),
|
||||
),
|
||||
),
|
||||
if (playlist.isSystem) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
playlist.systemVariant!.replaceAll('_', ' '),
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${playlist.trackCount} ${playlist.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right, color: fs.ash),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../api/endpoints/playlists.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/adapters.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
|
||||
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
||||
return PlaylistsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Drift-first per #357 plan C. Returns cached playlists filtered to
|
||||
/// match the `kind` family arg:
|
||||
/// - 'user' → only user-created (systemVariant null), owned by current user
|
||||
/// - 'system' → only system-generated (systemVariant non-null), owned
|
||||
/// - 'all' → everything: own user playlists + own system + others' public
|
||||
///
|
||||
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
|
||||
/// behavior leaked system playlists into add-to-playlist sheet because we
|
||||
/// couldn't filter locally.
|
||||
final playlistsListProvider =
|
||||
StreamProvider.family<PlaylistsList, String>((ref, kind) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
return cacheFirst<CachedPlaylist, PlaylistsList>(
|
||||
driftStream: db.select(db.cachedPlaylists).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.list(kind: kind);
|
||||
|
||||
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
||||
// every rebuild, so insertOrReplace alone leaves stale rows in
|
||||
// drift. Tapping one of those stale tiles 404s. Delete every
|
||||
// owned drift row whose id isn't in the fresh response, then
|
||||
// upsert the fresh set.
|
||||
final freshOwnedIds =
|
||||
fresh.owned.map((p) => p.id).toSet();
|
||||
await db.batch((b) {
|
||||
if (user != null) {
|
||||
b.deleteWhere(db.cachedPlaylists, (t) {
|
||||
return t.userId.equals(user.id) &
|
||||
t.id.isNotIn(freshOwnedIds);
|
||||
});
|
||||
}
|
||||
for (final p in fresh.all) {
|
||||
b.insert(db.cachedPlaylists, p.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
}
|
||||
});
|
||||
},
|
||||
toResult: (rows) {
|
||||
if (user == null) return PlaylistsList.empty();
|
||||
final filtered = rows.where((r) {
|
||||
if (kind == 'user') return r.systemVariant == null;
|
||||
if (kind == 'system') return r.systemVariant != null;
|
||||
return true; // 'all'
|
||||
}).toList();
|
||||
final owned = filtered
|
||||
.where((r) => r.userId == user.id)
|
||||
.map((r) => r.toRef())
|
||||
.toList();
|
||||
final pub = filtered
|
||||
.where((r) => r.userId != user.id && r.isPublic)
|
||||
.map((r) => r.toRef())
|
||||
.toList();
|
||||
return PlaylistsList(owned: owned, public: pub);
|
||||
},
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// Aggregate list — server may add system playlists (For-You /
|
||||
// Discover) that the per-user delta sync doesn't always emit.
|
||||
// Stale-while-revalidate ensures the home tile row reflects the
|
||||
// current server state on every screen visit.
|
||||
alwaysRefresh: true,
|
||||
);
|
||||
});
|
||||
|
||||
/// Composite shape (playlist + tracks). async* over the playlist watch
|
||||
/// stream + a one-shot tracks fetch per emission.
|
||||
final playlistDetailProvider =
|
||||
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
final playlistQuery = db.select(db.cachedPlaylists)
|
||||
..where((t) => t.id.equals(id));
|
||||
|
||||
final tracksQuery = (db.select(db.cachedPlaylistTracks)
|
||||
..where((t) => t.playlistId.equals(id))
|
||||
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedTracks,
|
||||
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||
]);
|
||||
|
||||
PlaylistDetail emptyDetail() => PlaylistDetail(
|
||||
playlist: Playlist(
|
||||
id: id,
|
||||
userId: user?.id ?? '',
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
systemVariant: null,
|
||||
trackCount: 0,
|
||||
coverUrl: '',
|
||||
ownerUsername: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
),
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> fetchAndPopulate() async {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||
|
||||
// Collect the track + artist + album rows referenced by these
|
||||
// playlist entries. Without writing the cachedTracks rows
|
||||
// themselves, the detail-screen JOIN against cachedTracks
|
||||
// returns null on every row (only the playlist_tracks join
|
||||
// succeeds), so the UI shows a list with empty titles and
|
||||
// unplayable tracks.
|
||||
final tracks = <String, _TrackRefRow>{};
|
||||
final artists = <String, ArtistRefRow>{};
|
||||
final albums = <String, AlbumRefRow>{};
|
||||
for (final t in fresh.tracks) {
|
||||
final tId = t.trackId;
|
||||
if (tId != null && tId.isNotEmpty) {
|
||||
tracks.putIfAbsent(
|
||||
tId,
|
||||
() => _TrackRefRow(
|
||||
id: tId,
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? '',
|
||||
durationSec: t.durationSec,
|
||||
),
|
||||
);
|
||||
}
|
||||
final aId = t.artistId;
|
||||
final aName = t.artistName;
|
||||
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
||||
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
|
||||
}
|
||||
final lbId = t.albumId;
|
||||
final lbTitle = t.albumTitle;
|
||||
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
|
||||
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
|
||||
// Wipe + re-insert this playlist's track positions so deletions
|
||||
// propagate. Without the wipe, removed tracks would linger in
|
||||
// drift after the playlist mutated server-side.
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedArtists,
|
||||
artists.values
|
||||
.map((a) => CachedArtistsCompanion.insert(
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
sortName: a.name,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (tracks.isNotEmpty) {
|
||||
// Insert/update cachedTracks so the detail screen's JOIN
|
||||
// produces real titles + durations. We don't have
|
||||
// track_number / disc_number on the wire (PlaylistTrack
|
||||
// omits them), so they default to 0 — UI doesn't surface
|
||||
// them on this screen so it's fine.
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedTracks,
|
||||
tracks.values
|
||||
.map((t) => CachedTracksCompanion.insert(
|
||||
id: t.id,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
title: t.title,
|
||||
durationMs: drift.Value(t.durationSec * 1000),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (albums.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums,
|
||||
albums.values
|
||||
.map((a) => CachedAlbumsCompanion.insert(
|
||||
id: a.id,
|
||||
artistId: a.artistId,
|
||||
title: a.title,
|
||||
sortTitle: a.title,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
||||
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
|
||||
// tiles can outlive the actual server-side playlist). Wipe the
|
||||
// stale row + its track positions so the home tile disappears
|
||||
// on next render and we don't keep trying.
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
await db.batch((b) {
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Once-per-subscription guard so an empty server response doesn't
|
||||
// cause repeated re-fetches.
|
||||
var fetchAttempted = false;
|
||||
|
||||
await for (final playlistRows in playlistQuery.watch()) {
|
||||
if (playlistRows.isEmpty) {
|
||||
if (fetchAttempted) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
fetchAttempted = true;
|
||||
if (!await isOnline()) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
final ok = await fetchAndPopulate();
|
||||
if (!ok) yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
|
||||
final playlist = playlistRows.first.toRef();
|
||||
final trackRows = await tracksQuery.get();
|
||||
|
||||
// Same pattern as albumProvider: playlist row exists but no tracks
|
||||
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
|
||||
// tracks for system playlists). Trigger the same fetch, drift
|
||||
// watch re-emits with populated tracks.
|
||||
if (trackRows.isEmpty && !fetchAttempted) {
|
||||
fetchAttempted = true;
|
||||
if (await isOnline()) {
|
||||
final ok = await fetchAndPopulate();
|
||||
if (ok) continue; // wait for watch re-emit
|
||||
}
|
||||
}
|
||||
|
||||
final tracks = trackRows.asMap().entries.map((e) {
|
||||
final r = e.value;
|
||||
final track = r.readTableOrNull(db.cachedTracks);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
final album = r.readTableOrNull(db.cachedAlbums);
|
||||
return PlaylistTrack(
|
||||
position: e.key,
|
||||
trackId: track?.id,
|
||||
title: track?.title ?? '',
|
||||
albumId: album?.id,
|
||||
albumTitle: album?.title ?? '',
|
||||
artistId: artist?.id,
|
||||
artistName: artist?.name ?? '',
|
||||
durationSec: track == null ? 0 : track.durationMs ~/ 1000,
|
||||
streamUrl: null,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||
|
||||
// No SWR refresh here. The aggregate playlistsListProvider does
|
||||
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
|
||||
// refresh on every visit was multiplying with the prefetcher's
|
||||
// parallel fetches and starving user-initiated playback. Pull-
|
||||
// to-refresh on the detail page invalidates the provider, which
|
||||
// is the right path for an explicit refresh.
|
||||
}
|
||||
});
|
||||
|
||||
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
|
||||
/// and album rows referenced by playlist tracks before we batch-write
|
||||
/// them to drift.
|
||||
class ArtistRefRow {
|
||||
ArtistRefRow(this.id, this.name);
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class AlbumRefRow {
|
||||
AlbumRefRow(this.id, this.title, this.artistId);
|
||||
final String id;
|
||||
final String title;
|
||||
final String artistId;
|
||||
}
|
||||
|
||||
class _TrackRefRow {
|
||||
_TrackRefRow({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.artistId,
|
||||
required this.durationSec,
|
||||
});
|
||||
final String id;
|
||||
final String title;
|
||||
final String albumId;
|
||||
final String artistId;
|
||||
final int durationSec;
|
||||
}
|
||||
|
||||
final systemPlaylistsStatusProvider =
|
||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return MeApi(dio).systemPlaylistsStatus();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/playlist.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
|
||||
/// name + optional system-variant badge below. Tap pushes
|
||||
/// `/playlists/{id}`.
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
const PlaylistCard({super.key, required this.playlist});
|
||||
|
||||
final Playlist playlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
context.push('/playlists/${playlist.id}', extra: playlist),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
// coverUrl is deterministic per playlist (see
|
||||
// CachedPlaylistAdapter.toRef). When the server's
|
||||
// collage isn't built yet, ServerImage's
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback:
|
||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (playlist.isSystem)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
playlist.systemVariant!.replaceAll('_', ' '),
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Same dimensions as PlaylistCard so the home Playlists row keeps
|
||||
/// visual rhythm whether real or placeholder. Mirrors the web
|
||||
/// PlaylistPlaceholderCard. Variant decides the state copy below
|
||||
/// the label.
|
||||
class PlaylistPlaceholderCard extends StatelessWidget {
|
||||
const PlaylistPlaceholderCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.variant,
|
||||
});
|
||||
|
||||
/// "For You" or "Songs like…" — what the slot is reserved for.
|
||||
final String label;
|
||||
|
||||
/// One of: 'building' | 'failed' | 'pending' | 'seed-needed'.
|
||||
final String variant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: fs.slate, width: 1),
|
||||
),
|
||||
child: Center(child: _stateIcon(fs)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
_stateText(),
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stateIcon(FabledSwordTheme fs) {
|
||||
switch (variant) {
|
||||
case 'building':
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: fs.accent),
|
||||
);
|
||||
case 'failed':
|
||||
return Icon(Icons.warning_amber, color: fs.error, size: 32);
|
||||
default:
|
||||
return Icon(Icons.queue_music, color: fs.ash, size: 40);
|
||||
}
|
||||
}
|
||||
|
||||
String _stateText() {
|
||||
switch (variant) {
|
||||
case 'building':
|
||||
return 'Building…';
|
||||
case 'failed':
|
||||
return "Couldn't generate";
|
||||
case 'seed-needed':
|
||||
return 'Like more music';
|
||||
default:
|
||||
return 'Coming soon';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../api/endpoints/quarantine.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
|
||||
final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
|
||||
return QuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
||||
@override
|
||||
Future<List<QuarantineMineRow>> build() async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return MeApi(dio).quarantineMine();
|
||||
}
|
||||
|
||||
/// True when this track is in the caller's quarantine list.
|
||||
bool isHidden(String trackId) =>
|
||||
(state.value ?? const []).any((r) => r.trackId == trackId);
|
||||
|
||||
/// Optimistic flag: prepends a synthetic row, calls server,
|
||||
/// rolls back on error.
|
||||
Future<void> flag(TrackRef track, String reason, String notes) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
if (current.any((r) => r.trackId == track.id)) return; // already hidden
|
||||
final synthetic = QuarantineMineRow(
|
||||
trackId: track.id,
|
||||
reason: reason,
|
||||
notes: notes.isEmpty ? null : notes,
|
||||
createdAt: DateTime.now().toUtc().toIso8601String(),
|
||||
trackTitle: track.title,
|
||||
trackDurationMs: track.durationSec * 1000,
|
||||
albumId: track.albumId,
|
||||
albumTitle: track.albumTitle,
|
||||
artistId: track.artistId,
|
||||
artistName: track.artistName,
|
||||
);
|
||||
state = AsyncData([synthetic, ...current]);
|
||||
try {
|
||||
await api.flag(track.id, reason, notes: notes);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic unflag: removes the row, calls server, rolls back on error.
|
||||
Future<void> unflag(String trackId) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
final removed = current.where((r) => r.trackId == trackId).toList();
|
||||
if (removed.isEmpty) return;
|
||||
state = AsyncData(current.where((r) => r.trackId != trackId).toList());
|
||||
try {
|
||||
await api.unflag(trackId);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final myQuarantineProvider =
|
||||
AsyncNotifierProvider<MyQuarantineController, List<QuarantineMineRow>>(
|
||||
MyQuarantineController.new,
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/requests.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/admin_request.dart';
|
||||
|
||||
final requestsApiProvider = FutureProvider<RequestsApi>((ref) async {
|
||||
return RequestsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh
|
||||
/// every 12s while any row is mid-ingest (status='approved'), stop when
|
||||
/// all rows settle. Polls regardless of app foreground/background — a
|
||||
/// future enhancement could pause via WidgetsBindingObserver, but for
|
||||
/// v1 the small extra refresh is acceptable.
|
||||
class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
||||
static const Duration _pollInterval = Duration(seconds: 12);
|
||||
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async {
|
||||
ref.onDispose(() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
});
|
||||
final api = await ref.watch(requestsApiProvider.future);
|
||||
final rows = await api.listMine();
|
||||
_maybeStartPolling(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _maybeStartPolling(List<AdminRequest> rows) {
|
||||
_pollTimer?.cancel();
|
||||
if (rows.any((r) => r.status == 'approved')) {
|
||||
_pollTimer = Timer.periodic(_pollInterval, (_) {
|
||||
ref.invalidateSelf();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic remove + REST cancel. Restores the row on failure so
|
||||
/// the user can retry — silent failure would lie about success.
|
||||
Future<void> cancel(String id) async {
|
||||
final api = await ref.read(requestsApiProvider.future);
|
||||
final current = state.value ?? const <AdminRequest>[];
|
||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||
try {
|
||||
await api.cancel(id);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final myRequestsProvider =
|
||||
AsyncNotifierProvider<MyRequestsController, List<AdminRequest>>(
|
||||
MyRequestsController.new);
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/admin_request.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'requests_provider.dart';
|
||||
|
||||
class RequestsScreen extends ConsumerWidget {
|
||||
const RequestsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final requests = ref.watch(myRequestsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/requests')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing requested yet.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(myRequestsProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(myRequestsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: rows.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1),
|
||||
itemBuilder: (_, i) => _RequestRow(
|
||||
key: Key('request_row_${rows[i].id}'),
|
||||
request: rows[i],
|
||||
onCancel: () => notifier.cancel(rows[i].id),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestRow extends StatelessWidget {
|
||||
const _RequestRow({
|
||||
super.key,
|
||||
required this.request,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
final AdminRequest request;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final href = _listenHref(request);
|
||||
return ListTile(
|
||||
leading: _kindAvatar(fs),
|
||||
title: Text(
|
||||
request.displayName,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_KindPill(label: request.kind),
|
||||
const SizedBox(width: 6),
|
||||
_StatusPill(status: request.status),
|
||||
],
|
||||
),
|
||||
if (request.importedAlbumCount > 0 ||
|
||||
request.importedTrackCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_ingestProgressText(request),
|
||||
style: TextStyle(color: fs.accent, fontSize: 13),
|
||||
),
|
||||
],
|
||||
if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _trailing(context, fs, href),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindAvatar(FabledSwordTheme fs) {
|
||||
final icon = switch (request.kind) {
|
||||
'artist' => Icons.album,
|
||||
'album' => Icons.library_music,
|
||||
_ => Icons.music_note,
|
||||
};
|
||||
return CircleAvatar(
|
||||
backgroundColor: fs.iron,
|
||||
child: Icon(icon, size: 20, color: fs.ash),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) {
|
||||
if (request.status == 'pending') {
|
||||
return TextButton.icon(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Cancel request?'),
|
||||
content: Text('Cancel "${request.displayName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Keep'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Cancel request'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) onCancel();
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
label: const Text('Cancel'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.ash),
|
||||
);
|
||||
}
|
||||
if (request.status == 'completed' && href != null) {
|
||||
return TextButton.icon(
|
||||
onPressed: () => context.push(href),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: const Text('Listen'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.accent),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _ingestProgressText(AdminRequest r) {
|
||||
if (r.kind == 'artist') {
|
||||
final albums = '${r.importedAlbumCount} '
|
||||
'${r.importedAlbumCount == 1 ? 'album' : 'albums'}';
|
||||
final tracks = '${r.importedTrackCount} '
|
||||
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
|
||||
return '$albums · $tracks';
|
||||
}
|
||||
if (r.kind == 'album') {
|
||||
return '${r.importedTrackCount} '
|
||||
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
|
||||
}
|
||||
return 'Track ingested';
|
||||
}
|
||||
|
||||
String? _listenHref(AdminRequest r) {
|
||||
if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}';
|
||||
if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}';
|
||||
if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _KindPill extends StatelessWidget {
|
||||
const _KindPill({required this.label});
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
const _StatusPill({required this.status});
|
||||
final String status;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final color = switch (status) {
|
||||
'pending' => fs.ash,
|
||||
'approved' => fs.bronze,
|
||||
'completed' => fs.moss,
|
||||
'rejected' => fs.oxblood,
|
||||
'failed' => fs.oxblood,
|
||||
_ => fs.ash,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(color: color, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/search.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/search_response.dart';
|
||||
|
||||
final searchApiProvider = FutureProvider<SearchApi>((ref) async {
|
||||
return SearchApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Current search query text. The screen's TextField writes here on
|
||||
/// every keystroke; the resultsProvider below debounces.
|
||||
class SearchQueryNotifier extends Notifier<String> {
|
||||
@override
|
||||
String build() => '';
|
||||
void set(String q) => state = q;
|
||||
}
|
||||
|
||||
final searchQueryProvider =
|
||||
NotifierProvider<SearchQueryNotifier, String>(SearchQueryNotifier.new);
|
||||
|
||||
/// Debounced search results. Returns null for empty queries so the
|
||||
/// screen can show a neutral empty state instead of "0 results."
|
||||
///
|
||||
/// Debounce: 250ms. After the wait, re-reads the live query — if the
|
||||
/// user has typed more in that window, this attempt is silently
|
||||
/// abandoned (returns null) and a newer attempt fires on the next
|
||||
/// rebuild. No request hits the server while the user is mid-typing.
|
||||
final searchResultsProvider = FutureProvider<SearchResponse?>((ref) async {
|
||||
final q = ref.watch(searchQueryProvider).trim();
|
||||
if (q.isEmpty) return null;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
// Re-read the (possibly newer) query; if the user typed more, bail.
|
||||
if (ref.read(searchQueryProvider).trim() != q) return null;
|
||||
final api = await ref.watch(searchApiProvider.future);
|
||||
return api.search(q);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../library/widgets/album_card.dart';
|
||||
import '../library/widgets/artist_card.dart';
|
||||
import '../library/widgets/track_row.dart';
|
||||
import '../models/search_response.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Autofocus the field on screen entry; keyboard pops up so the
|
||||
// user can start typing immediately.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.requestFocus());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final results = ref.watch(searchResultsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
cursorColor: fs.accent,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search artists, albums, tracks',
|
||||
hintStyle: TextStyle(color: fs.ash),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) => ref.read(searchQueryProvider.notifier).set(v),
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
actions: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(Icons.clear, color: fs.ash),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).set('');
|
||||
_focus.requestFocus();
|
||||
},
|
||||
),
|
||||
const MainAppBarActions(currentRoute: '/search'),
|
||||
],
|
||||
),
|
||||
body: results.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
),
|
||||
data: (r) => r == null
|
||||
? const _Hint(message: 'Type to search your library.')
|
||||
: r.isEmpty
|
||||
? const _Hint(message: 'No matches for that query.')
|
||||
: _Results(results: r),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Hint extends StatelessWidget {
|
||||
const _Hint({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Center(
|
||||
child: Text(message, style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Results extends ConsumerWidget {
|
||||
const _Results({required this.results});
|
||||
final SearchResponse results;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView(
|
||||
children: [
|
||||
if (results.artists.items.isNotEmpty) ...[
|
||||
_Header(label: 'Artists', count: results.artists.total),
|
||||
SizedBox(
|
||||
height: 168,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: results.artists.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final a = results.artists.items[i];
|
||||
return ArtistCard(
|
||||
artist: a,
|
||||
onTap: () => ctx.push('/artists/${a.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (results.albums.items.isNotEmpty) ...[
|
||||
_Header(label: 'Albums', count: results.albums.total),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: results.albums.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final a = results.albums.items[i];
|
||||
return AlbumCard(
|
||||
album: a,
|
||||
onTap: () => ctx.push('/albums/${a.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (results.tracks.items.isNotEmpty) ...[
|
||||
_Header(label: 'Tracks', count: results.tracks.total),
|
||||
...results.tracks.items.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final t = e.value;
|
||||
return TrackRow(
|
||||
track: t,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(results.tracks.items, initialIndex: i),
|
||||
);
|
||||
}),
|
||||
],
|
||||
const SizedBox(height: 96),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({required this.label, required this.count});
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/settings.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/my_profile.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'storage_section.dart';
|
||||
import '../theme/theme_mode_provider.dart';
|
||||
|
||||
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
||||
return SettingsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _profileProvider = FutureProvider<MyProfile>((ref) async {
|
||||
return (await ref.watch(_settingsApiProvider.future)).getProfile();
|
||||
});
|
||||
|
||||
final _lbStatusProvider = FutureProvider<ListenBrainzStatus>((ref) async {
|
||||
return (await ref.watch(_settingsApiProvider.future)).getListenBrainz();
|
||||
});
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Settings', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/settings')],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: const [
|
||||
_ProfileSection(),
|
||||
_Divider(),
|
||||
_RequestsSection(),
|
||||
_Divider(),
|
||||
_AppearanceSection(),
|
||||
_Divider(),
|
||||
StorageSection(),
|
||||
_Divider(),
|
||||
_PasswordSection(),
|
||||
_Divider(),
|
||||
_ListenBrainzSection(),
|
||||
_AdminSection(),
|
||||
SizedBox(height: 96),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
const _Divider();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Container(height: 1, color: fs.iron),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.label);
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestsSection extends StatelessWidget {
|
||||
const _RequestsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
key: const Key('settings_requests_card'),
|
||||
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||
title: Text(
|
||||
'My requests',
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Track what you've asked Minstrel to add",
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: () => context.push('/requests'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an "Admin" entry only when the current profile has
|
||||
/// `is_admin = true`. Returns an empty SizedBox otherwise so the
|
||||
/// const-children layout above stays valid.
|
||||
class _AdminSection extends ConsumerWidget {
|
||||
const _AdminSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final profile = ref.watch(_profileProvider).value;
|
||||
if (profile == null || !profile.isAdmin) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _Divider(),
|
||||
ListTile(
|
||||
key: const Key('settings_admin_card'),
|
||||
leading: Icon(Icons.shield, color: fs.parchment),
|
||||
title: Text(
|
||||
'Admin',
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Manage requests, quarantine, and users',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: () => context.push('/admin'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileSection extends ConsumerStatefulWidget {
|
||||
const _ProfileSection();
|
||||
@override
|
||||
ConsumerState<_ProfileSection> createState() => _ProfileSectionState();
|
||||
}
|
||||
|
||||
class _ProfileSectionState extends ConsumerState<_ProfileSection> {
|
||||
final _displayName = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
bool _initialized = false;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_displayName.dispose();
|
||||
_email.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = await ref.read(_settingsApiProvider.future);
|
||||
await api.updateProfile(
|
||||
displayName: _displayName.text.trim(),
|
||||
email: _email.text.trim(),
|
||||
);
|
||||
ref.invalidate(_profileProvider);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Profile saved.'),
|
||||
backgroundColor: fs.iron,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final msg = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Save failed: $msg'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final profile = ref.watch(_profileProvider);
|
||||
return profile.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (p) {
|
||||
if (!_initialized) {
|
||||
_displayName.text = p.displayName ?? '';
|
||||
_email.text = p.email ?? '';
|
||||
_initialized = true;
|
||||
}
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const _SectionHeader('Profile'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
||||
child: Text(
|
||||
'Signed in as ${p.username}${p.isAdmin ? " · admin" : ""}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _displayName,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
decoration: _inputDecoration(fs, 'Display name'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _email,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: _inputDecoration(fs, 'Email (for password reset)'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(_saving ? 'Saving…' : 'Save profile'),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordSection extends ConsumerStatefulWidget {
|
||||
const _PasswordSection();
|
||||
@override
|
||||
ConsumerState<_PasswordSection> createState() => _PasswordSectionState();
|
||||
}
|
||||
|
||||
class _PasswordSectionState extends ConsumerState<_PasswordSection> {
|
||||
final _current = TextEditingController();
|
||||
final _next = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_current.dispose();
|
||||
_next.dispose();
|
||||
_confirm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _change() async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
if (_next.text != _confirm.text) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: const Text('New passwords do not match.'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (_next.text.length < 8) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: const Text('Password must be at least 8 characters.'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = await ref.read(_settingsApiProvider.future);
|
||||
await api.changePassword(current: _current.text, next: _next.text);
|
||||
_current.clear();
|
||||
_next.clear();
|
||||
_confirm.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: const Text('Password changed.'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final msg = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Change failed: $msg'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const _SectionHeader('Password'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _current,
|
||||
obscureText: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
decoration: _inputDecoration(fs, 'Current password'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _next,
|
||||
obscureText: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
decoration: _inputDecoration(fs, 'New password'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _confirm,
|
||||
obscureText: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
decoration: _inputDecoration(fs, 'Confirm new password'),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _change,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(_saving ? 'Changing…' : 'Change password'),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListenBrainzSection extends ConsumerStatefulWidget {
|
||||
const _ListenBrainzSection();
|
||||
@override
|
||||
ConsumerState<_ListenBrainzSection> createState() =>
|
||||
_ListenBrainzSectionState();
|
||||
}
|
||||
|
||||
class _ListenBrainzSectionState extends ConsumerState<_ListenBrainzSection> {
|
||||
final _token = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_token.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveToken() async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
if (_token.text.trim().isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = await ref.read(_settingsApiProvider.future);
|
||||
await api.setListenBrainzToken(_token.text.trim());
|
||||
_token.clear();
|
||||
ref.invalidate(_lbStatusProvider);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: const Text('Token saved.'),
|
||||
backgroundColor: fs.iron,
|
||||
));
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final msg = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Save failed: $msg'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleEnabled(bool value) async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
try {
|
||||
final api = await ref.read(_settingsApiProvider.future);
|
||||
await api.setListenBrainzEnabled(value);
|
||||
ref.invalidate(_lbStatusProvider);
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
final msg = ApiError.fromDio(e).code;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Toggle failed: $msg'),
|
||||
backgroundColor: fs.error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final status = ref.watch(_lbStatusProvider);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const _SectionHeader('ListenBrainz'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
'Get a token at listenbrainz.org/profile. Tokens are stored '
|
||||
'unencrypted on this server — treat as sensitive.',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
),
|
||||
status.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (s) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _token,
|
||||
obscureText: true,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
decoration: _inputDecoration(
|
||||
fs,
|
||||
s.tokenSet ? 'Update token (current is set)' : 'Paste token',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _saveToken,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fs.accent,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(_saving ? 'Saving…' : 'Save token'),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'Send my plays to ListenBrainz',
|
||||
style: TextStyle(color: fs.parchment),
|
||||
),
|
||||
subtitle: s.lastScrobbledAt != null
|
||||
? Text(
|
||||
'Last scrobble: ${s.lastScrobbledAt}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
)
|
||||
: null,
|
||||
value: s.enabled,
|
||||
activeThumbColor: fs.accent,
|
||||
onChanged: s.tokenSet ? _toggleEnabled : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(FabledSwordTheme fs, String label) {
|
||||
return InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: fs.ash),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.iron),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: fs.accent),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AppearanceSection extends ConsumerWidget {
|
||||
const _AppearanceSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final current = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||
// RadioGroup is the post-Flutter-3.32 API: the ancestor owns
|
||||
// groupValue/onChanged so individual RadioListTiles don't have
|
||||
// to repeat them. Pre-3.32 RadioListTile.groupValue/onChanged
|
||||
// are deprecated.
|
||||
return RadioGroup<AppThemeMode>(
|
||||
groupValue: current,
|
||||
onChanged: (m) {
|
||||
if (m != null) {
|
||||
ref.read(themeModeProvider.notifier).set(m);
|
||||
}
|
||||
},
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const _SectionHeader('Appearance'),
|
||||
for (final mode in AppThemeMode.values)
|
||||
RadioListTile<AppThemeMode>(
|
||||
key: Key('appearance_${mode.name}'),
|
||||
title: Text(_label(mode), style: TextStyle(color: fs.parchment)),
|
||||
subtitle: mode == AppThemeMode.system
|
||||
? Text('Match the device setting',
|
||||
style: TextStyle(color: fs.ash))
|
||||
: null,
|
||||
value: mode,
|
||||
activeColor: fs.accent,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(AppThemeMode m) => switch (m) {
|
||||
AppThemeMode.system => 'System',
|
||||
AppThemeMode.light => 'Light',
|
||||
AppThemeMode.dark => 'Dark',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/cache_settings_provider.dart';
|
||||
import '../cache/sync_controller.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
|
||||
/// Settings card: usage display, cap selector, prefetch window selector,
|
||||
/// cache-liked toggle, Clear cache + Sync now buttons.
|
||||
class StorageSection extends ConsumerStatefulWidget {
|
||||
const StorageSection({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StorageSection> createState() => _StorageSectionState();
|
||||
}
|
||||
|
||||
class _StorageSectionState extends ConsumerState<StorageSection> {
|
||||
int? _usageBytes;
|
||||
bool _syncing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshUsage();
|
||||
}
|
||||
|
||||
Future<void> _refreshUsage() async {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
final used = await mgr.usageBytes();
|
||||
if (mounted) setState(() => _usageBytes = used);
|
||||
}
|
||||
|
||||
String _fmtBytes(int? n) {
|
||||
if (n == null) return '—';
|
||||
if (n == 0) return '0 B';
|
||||
if (n < 1024) return '$n B';
|
||||
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
|
||||
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
|
||||
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final settings = ref.watch(cacheSettingsProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Card(
|
||||
color: fs.iron,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: settings.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Text('$e', style: TextStyle(color: fs.error)),
|
||||
data: (s) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Storage',
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Text('Cache usage', style: TextStyle(color: fs.ash)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_fmtBytes(_usageBytes)} / '
|
||||
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
|
||||
style: TextStyle(color: fs.parchment),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_capSelector(s, fs),
|
||||
const SizedBox(height: 8),
|
||||
_prefetchSelector(s, fs),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
key: const Key('cache_liked_toggle'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Cache liked tracks',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
value: s.cacheLikedTracks,
|
||||
onChanged: (v) => ref
|
||||
.read(cacheSettingsProvider.notifier)
|
||||
.setCacheLikedTracks(v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
OutlinedButton(
|
||||
key: const Key('clear_cache_button'),
|
||||
onPressed: _confirmClear,
|
||||
child: const Text('Clear cache'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
key: const Key('sync_now_button'),
|
||||
onPressed: _syncing
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _syncing = true);
|
||||
try {
|
||||
await ref
|
||||
.read(syncControllerProvider.notifier)
|
||||
.sync();
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
},
|
||||
icon: _syncing
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync, size: 16),
|
||||
label: const Text('Sync now'),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
|
||||
const options = [
|
||||
(1024 * 1024 * 1024, '1 GB'),
|
||||
(5 * 1024 * 1024 * 1024, '5 GB'),
|
||||
(10 * 1024 * 1024 * 1024, '10 GB'),
|
||||
(25 * 1024 * 1024 * 1024, '25 GB'),
|
||||
(0, 'Unlimited'),
|
||||
];
|
||||
return Row(children: [
|
||||
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
|
||||
DropdownButton<int>(
|
||||
key: const Key('cap_selector'),
|
||||
value: options.any((o) => o.$1 == s.capBytes)
|
||||
? s.capBytes
|
||||
: 5 * 1024 * 1024 * 1024,
|
||||
items: options
|
||||
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
|
||||
.toList(),
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
|
||||
if (v > 0) {
|
||||
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
|
||||
}
|
||||
await _refreshUsage();
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
|
||||
const options = [1, 3, 5, 7, 10];
|
||||
return Row(children: [
|
||||
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
|
||||
DropdownButton<int>(
|
||||
key: const Key('prefetch_selector'),
|
||||
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
|
||||
items: options
|
||||
.map((n) => DropdownMenuItem(value: n, child: Text('$n tracks')))
|
||||
.toList(),
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await ref.read(cacheSettingsProvider.notifier).setPrefetchWindow(v);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _confirmClear() async {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear cache?'),
|
||||
content: const Text(
|
||||
'This deletes all cached audio files (including manually downloaded ones). '
|
||||
'The next play of any track will re-download from the server.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: fs.error),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await ref.read(audioCacheManagerProvider).clearAll();
|
||||
await _refreshUsage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Renders [whileDelayed] only after [isLoading] has been true
|
||||
/// continuously for [delay], then renders [whenReady] once loading
|
||||
/// completes. Mirrors the web `useDelayed` hook: a brief loading flash
|
||||
/// doesn't trigger a skeleton; sustained loading does.
|
||||
///
|
||||
/// Once [isLoading] flips back to false, the timer resets.
|
||||
class DelayedLoading extends StatefulWidget {
|
||||
const DelayedLoading({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.whileDelayed,
|
||||
required this.whenReady,
|
||||
this.delay = const Duration(milliseconds: 200),
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final Widget whileDelayed;
|
||||
final Widget whenReady;
|
||||
final Duration delay;
|
||||
|
||||
@override
|
||||
State<DelayedLoading> createState() => _DelayedLoadingState();
|
||||
}
|
||||
|
||||
class _DelayedLoadingState extends State<DelayedLoading> {
|
||||
Timer? _timer;
|
||||
bool _delayPassed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_maybeStart();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DelayedLoading old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.isLoading != old.isLoading) {
|
||||
_timer?.cancel();
|
||||
_delayPassed = false;
|
||||
_maybeStart();
|
||||
}
|
||||
}
|
||||
|
||||
void _maybeStart() {
|
||||
if (!widget.isLoading) return;
|
||||
_timer = Timer(widget.delay, () {
|
||||
if (mounted) setState(() => _delayPassed = true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.isLoading) return widget.whenReady;
|
||||
return _delayPassed ? widget.whileDelayed : const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,26 @@ import '../auth/login_screen.dart';
|
||||
import '../auth/server_url_screen.dart';
|
||||
import '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
import '../player/now_playing_screen.dart';
|
||||
import '../player/player_bar.dart';
|
||||
import '../player/queue_screen.dart';
|
||||
import '../playlists/playlist_detail_screen.dart';
|
||||
import '../playlists/playlists_list_screen.dart';
|
||||
import '../requests/requests_screen.dart';
|
||||
import '../search/search_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../update/client_update_provider.dart';
|
||||
import '../update/update_banner.dart';
|
||||
import '../admin/admin_landing_screen.dart';
|
||||
import '../admin/admin_requests_screen.dart';
|
||||
import '../admin/admin_quarantine_screen.dart';
|
||||
import '../admin/admin_users_screen.dart';
|
||||
import 'widgets/version_gate.dart';
|
||||
|
||||
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
||||
@@ -32,39 +49,118 @@ GoRouter buildRouter(Ref ref) {
|
||||
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
||||
return '/home';
|
||||
}
|
||||
if (loc.startsWith('/admin') && !user!.isAdmin) {
|
||||
return '/home';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
||||
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
// /now-playing lives outside the ShellRoute on purpose. The full
|
||||
// player IS the player UI when active, and we don't want the mini
|
||||
// bar from the shell underneath fighting for the bottom strip.
|
||||
// Pushing this route unmounts the shell entirely; the slide-up
|
||||
// transition still feels right because the shell stays painted
|
||||
// for the duration of the animation.
|
||||
GoRoute(
|
||||
path: '/now-playing',
|
||||
pageBuilder: (_, __) => CustomTransitionPage(
|
||||
child: const NowPlayingScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||
transitionsBuilder: (_, anim, __, child) {
|
||||
final eased = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(eased),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// /queue lives outside the ShellRoute too. Why: pushing /queue
|
||||
// from /now-playing (which is also outside the shell) used to
|
||||
// cause go_router to mount a second ShellRoute instance under
|
||||
// the existing one, producing a duplicate page-key assertion
|
||||
// (NavigatorState._debugCheckDuplicatedPageKeys). Top-level
|
||||
// routes can stack on each other freely; shell-children can't
|
||||
// when something on top of the shell is already routing.
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||
routes: [
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/artists/:id',
|
||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
||||
// `extra` carries an optional ArtistRef so the detail
|
||||
// header renders immediately while tracks/albums load.
|
||||
builder: (_, s) => ArtistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => AlbumDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||
GoRoute(
|
||||
path: '/playlists/:id',
|
||||
builder: (_, s) => PlaylistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is Playlist ? s.extra as Playlist : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
|
||||
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
|
||||
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _ShellWithPlayerBar extends StatelessWidget {
|
||||
class _ShellWithPlayerBar extends ConsumerWidget {
|
||||
const _ShellWithPlayerBar({required this.child});
|
||||
final Widget child;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The banner sits above the routed child and uses SafeArea to clear
|
||||
// the system status bar. MediaQuery.padding is screen-relative
|
||||
// though, so the child's Scaffold/AppBar would still apply its own
|
||||
// top status-bar inset on top of the banner — doubling the gap.
|
||||
// When the banner is showing, strip that inset from the child so
|
||||
// its AppBar lands directly under the banner.
|
||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: child),
|
||||
const UpdateBanner(),
|
||||
Expanded(
|
||||
child: hasBanner
|
||||
? MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
const PlayerBar(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Shared AppBar `actions` for top-level screens. Renders Home / Library /
|
||||
/// Search as primary icons (suppressing the icon for [currentRoute]) plus
|
||||
/// a kebab overflow with Playlists / Discover / Settings and (for admins)
|
||||
/// Admin.
|
||||
class MainAppBarActions extends ConsumerWidget {
|
||||
const MainAppBarActions({super.key, required this.currentRoute});
|
||||
|
||||
final String currentRoute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
final isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (currentRoute != '/home')
|
||||
IconButton(
|
||||
key: const Key('app_bar_home'),
|
||||
icon: Icon(Icons.home, color: fs.parchment),
|
||||
tooltip: 'Home',
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
if (currentRoute != '/library')
|
||||
IconButton(
|
||||
key: const Key('app_bar_library'),
|
||||
icon: Icon(Icons.library_music, color: fs.parchment),
|
||||
tooltip: 'Library',
|
||||
onPressed: () => context.push('/library'),
|
||||
),
|
||||
if (currentRoute != '/search')
|
||||
IconButton(
|
||||
key: const Key('app_bar_search'),
|
||||
icon: Icon(Icons.search, color: fs.parchment),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('app_bar_overflow'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
tooltip: 'More',
|
||||
onSelected: (route) => context.push(route),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: '/playlists', child: Text('Playlists')),
|
||||
const PopupMenuItem(value: '/discover', child: Text('Discover')),
|
||||
const PopupMenuItem(value: '/settings', child: Text('Settings')),
|
||||
if (isAdmin)
|
||||
const PopupMenuItem(value: '/admin', child: Text('Admin')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
|
||||
/// Image.network wrapper that resolves server-relative URLs (e.g.
|
||||
/// `/api/albums/<id>/cover`) against the configured server base URL
|
||||
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
||||
/// unchanged. Empty/null base or empty URL render the [fallback]
|
||||
/// (defaults to a transparent SizedBox so callers can supply their own
|
||||
/// surrounding placeholder).
|
||||
///
|
||||
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
||||
/// stream URLs, so cover art and audio resolve URLs the same way.
|
||||
class ServerImage extends ConsumerWidget {
|
||||
const ServerImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.fit,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final BoxFit? fit;
|
||||
final Widget? fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final empty = fallback ?? const SizedBox.shrink();
|
||||
if (url.isEmpty) return empty;
|
||||
final base = ref.watch(serverUrlProvider).value;
|
||||
final resolved = _resolve(base, url);
|
||||
if (resolved == null) return empty;
|
||||
// Cover endpoints are gated by RequireUser server-side. Image.network
|
||||
// doesn't carry cookies/Bearer tokens automatically the way the
|
||||
// browser's <img> tag does, so we forward the session token as a
|
||||
// header. Without this, the server returns 401 and the image fails.
|
||||
//
|
||||
// sessionTokenProvider is a FutureProvider — on first read after a
|
||||
// hot restart its .value is null until the secure-storage read
|
||||
// resolves. If we fired Image.network with headers:null at that
|
||||
// moment, the network image cache would lock in the 401 response
|
||||
// and never retry. Instead, hold the fallback until the token is
|
||||
// available, then mount the Image with the auth header attached.
|
||||
final tokenAsync = ref.watch(sessionTokenProvider);
|
||||
return tokenAsync.when(
|
||||
loading: () => empty,
|
||||
error: (_, __) => empty,
|
||||
data: (token) {
|
||||
final headers = (token != null && token.isNotEmpty)
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null;
|
||||
return Image.network(
|
||||
resolved,
|
||||
fit: fit,
|
||||
headers: headers,
|
||||
// Keep failures local — a single 401/timeout shouldn't dump
|
||||
// a stack trace or replace the parent Container's background.
|
||||
errorBuilder: (_, __, ___) => empty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolve(String? baseUrl, String url) {
|
||||
final parsed = Uri.tryParse(url);
|
||||
if (parsed != null && parsed.hasScheme) return url;
|
||||
if (baseUrl == null || baseUrl.isEmpty) return null;
|
||||
final base = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
final path = url.startsWith('/') ? url : '/$url';
|
||||
return '$base$path';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user