Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -56,6 +56,12 @@ jobs:
|
|||||||
- name: Pub get
|
- name: Pub get
|
||||||
run: flutter 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
|
- name: Analyze
|
||||||
run: flutter analyze --fatal-infos
|
run: flutter analyze --fatal-infos
|
||||||
|
|
||||||
@@ -66,9 +72,44 @@ jobs:
|
|||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||||
run: flutter build apk --debug
|
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
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
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
|
- name: Upload debug APK artifact
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||||
@@ -81,14 +122,15 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
# Look up the release ID for this tag (release must exist already;
|
# Look up the release ID for this tag. The release object must
|
||||||
# release.yml creates it as part of the server-side release flow).
|
# exist already — operator creates it (or release.yml will, in
|
||||||
|
# a future enhancement) before pushing the tag.
|
||||||
RELEASE_ID=$(curl -fsSL \
|
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}" \
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||||
if [ -z "${RELEASE_ID}" ]; then
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
@@ -96,6 +138,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
-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"
|
"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
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build: ${VERSION} + latest"
|
echo "::notice::Release build: ${VERSION} + latest"
|
||||||
else
|
else
|
||||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build: :main"
|
echo "::notice::Main-branch build: :main"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -60,9 +62,40 @@ jobs:
|
|||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
echo "${{ secrets.CI_TOKEN }}" \
|
||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| 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
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
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 binary, built with `go test -c`
|
||||||
*.test
|
*.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
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -15,7 +15,13 @@ COPY . .
|
|||||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||||
COPY --from=web /web/build ./web/build
|
COPY --from=web /web/build ./web/build
|
||||||
ENV CGO_ENABLED=0
|
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
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update \
|
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
|
# A non-writable path at this location silently breaks every downstream
|
||||||
# cache, so we create + chown it once at image build. Operators mount a
|
# cache, so we create + chown it once at image build. Operators mount a
|
||||||
# named volume on top to persist across container recreates.
|
# 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
|
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
|
USER minstrel
|
||||||
EXPOSE 4533
|
EXPOSE 4533
|
||||||
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ volumes:
|
|||||||
docker compose up -d
|
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).
|
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_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_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_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.
|
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"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
@@ -67,10 +66,6 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
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)
|
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
||||||
|
|
||||||
contact := cfg.Library.ContactEmail
|
contact := cfg.Library.ContactEmail
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ log:
|
|||||||
level: "info" # debug | info | warn | error
|
level: "info" # debug | info | warn | error
|
||||||
format: "text" # text | json
|
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:
|
library:
|
||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ services:
|
|||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
||||||
MINSTREL_STORAGE_DATA_DIR: /app/data
|
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:
|
networks:
|
||||||
- minstrel
|
- minstrel
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# drift codegen output (regenerated by build_runner; CI runs build_runner)
|
||||||
|
*.g.dart
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,38 @@ android {
|
|||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} 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">
|
<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.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<!-- 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
|
<application
|
||||||
android:label="minstrel"
|
android:label="minstrel"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -49,6 +60,20 @@
|
|||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
+67
-2
@@ -1,5 +1,70 @@
|
|||||||
package com.fabledsword.minstrel
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,16 @@ class LibraryListsApi {
|
|||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
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>>(
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
'/api/library/artists',
|
'/api/artists',
|
||||||
queryParameters: {'limit': limit, 'offset': offset},
|
queryParameters: {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'sort': 'alpha',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../models/history_event.dart';
|
import '../../models/history_event.dart';
|
||||||
import '../../models/quarantine_mine.dart';
|
import '../../models/quarantine_mine.dart';
|
||||||
|
import '../../models/system_playlists_status.dart';
|
||||||
|
|
||||||
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
||||||
class MeApi {
|
class MeApi {
|
||||||
@@ -27,4 +28,14 @@ class MeApi {
|
|||||||
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
.toList(growable: false);
|
.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 {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,55 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../models/playlist.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 {
|
class PlaylistsApi {
|
||||||
PlaylistsApi(this._dio);
|
PlaylistsApi(this._dio);
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
/// GET /api/playlists?kind=user|system|all. The server's default kind
|
/// GET /api/playlists?kind=user|system|all. Server returns
|
||||||
/// is "user" — passing "all" returns user playlists + system mixes.
|
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
|
||||||
/// "system" returns just for-you / discover. The mobile UI defaults
|
/// playlists, filtered by the kind param (default "user"). Public
|
||||||
/// to "all" so users see their generated mixes alongside their own.
|
/// is other users' shared playlists; not filtered by kind.
|
||||||
Future<List<Playlist>> list({String kind = 'all'}) async {
|
Future<PlaylistsList> list({String kind = 'user'}) async {
|
||||||
final r = await _dio.get<List<dynamic>>(
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
'/api/playlists',
|
'/api/playlists',
|
||||||
queryParameters: {'kind': kind},
|
queryParameters: {'kind': kind},
|
||||||
);
|
);
|
||||||
final raw = r.data ?? const [];
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
return raw
|
List<Playlist> parse(String key) {
|
||||||
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
final raw = (body[key] as List?) ?? const [];
|
||||||
.toList(growable: false);
|
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 {
|
Future<PlaylistDetail> get(String id) async {
|
||||||
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
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 {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,50 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 'shared/routing.dart';
|
||||||
import 'theme/theme_data.dart';
|
import 'theme/theme_data.dart';
|
||||||
|
import 'theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
class MinstrelApp extends ConsumerWidget {
|
class MinstrelApp extends ConsumerStatefulWidget {
|
||||||
const MinstrelApp({super.key});
|
const MinstrelApp({super.key});
|
||||||
|
|
||||||
@override
|
@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 router = ref.watch(routerProvider);
|
||||||
|
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Minstrel',
|
title: 'Minstrel',
|
||||||
theme: buildThemeData(),
|
theme: buildLightTheme(),
|
||||||
|
darkTheme: buildDarkTheme(),
|
||||||
|
themeMode: mode.materialMode,
|
||||||
routerConfig: router,
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
void log(String msg) {
|
||||||
|
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
|
||||||
|
}
|
||||||
|
|
||||||
|
await for (final rows in driftStream) {
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
log('drift hit (${rows.length} rows)');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
log('drift miss; checking connectivity');
|
||||||
|
if (await isOnline()) {
|
||||||
|
log('online; calling fetchAndPopulate');
|
||||||
|
try {
|
||||||
|
await fetchAndPopulate();
|
||||||
|
log('fetchAndPopulate done; awaiting drift re-emit');
|
||||||
|
// The drift watch() stream re-emits with the populated rows on
|
||||||
|
// the next loop iteration. Don't yield here.
|
||||||
|
} catch (e, st) {
|
||||||
|
log('fetchAndPopulate failed: $e\n$st');
|
||||||
|
yield toResult(rows); // empty result; caller surfaces error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('offline; yielding empty');
|
||||||
|
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,76 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
if (n > 0) debugPrint('metadataPrefetcher: warming $n artists');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -7,6 +7,7 @@ import '../api/endpoints/discover.dart';
|
|||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/lidarr.dart';
|
import '../models/lidarr.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
|
||||||
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
||||||
@@ -87,6 +88,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/discover')],
|
||||||
),
|
),
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -160,8 +162,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
|||||||
final rows = snap.data ?? const [];
|
final rows = snap.data ?? const [];
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('No matches.',
|
child: Text('Nothing to add for that search yet.',
|
||||||
style: TextStyle(color: fs.ash)),
|
style: TextStyle(color: fs.ash),
|
||||||
|
textAlign: TextAlign.center),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
|
|||||||
@@ -2,30 +2,105 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.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 '../likes/like_button.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/track_row.dart';
|
import 'widgets/track_row.dart';
|
||||||
|
|
||||||
class AlbumDetailScreen extends ConsumerWidget {
|
class AlbumDetailScreen extends ConsumerWidget {
|
||||||
const AlbumDetailScreen({required this.id, super.key});
|
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: ref.watch(albumProvider(id)).when(
|
body: live.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
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: [
|
data: (r) => ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: Column(
|
Expanded(child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -38,6 +113,21 @@ class AlbumDetailScreen extends ConsumerWidget {
|
|||||||
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
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(
|
Container(
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
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 '../api/endpoints/likes.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
|
|
||||||
class ArtistDetailScreen extends ConsumerWidget {
|
class ArtistDetailScreen extends ConsumerWidget {
|
||||||
const ArtistDetailScreen({required this.id, super.key});
|
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional artist reference from the caller. Lets the screen render
|
||||||
|
/// the name + avatar immediately while albums load.
|
||||||
|
final ArtistRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artist = ref.watch(artistProvider(id));
|
final artist = ref.watch(artistProvider(id));
|
||||||
final albums = ref.watch(artistAlbumsProvider(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(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: artist.when(
|
body: artist.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// While loading: render header from seed if available so the
|
||||||
data: (a) => ListView(children: [
|
// 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(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(
|
ClipOval(
|
||||||
width: 96, height: 96,
|
child: SizedBox(
|
||||||
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -51,10 +92,33 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
try {
|
||||||
if (tracks.isEmpty) return;
|
final tracks =
|
||||||
final shuffled = [...tracks]..shuffle();
|
await ref.read(artistTracksProvider(id).future);
|
||||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
debugPrint(
|
||||||
|
'artist_detail: play tapped — ${tracks.length} tracks for $id');
|
||||||
|
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 +132,79 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
albums.when(
|
albums.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||||
data: (list) => GridView.builder(
|
data: (list) {
|
||||||
shrinkWrap: true,
|
return LayoutBuilder(builder: (ctx, constraints) {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
const cols = 3;
|
||||||
padding: const EdgeInsets.all(8),
|
const sidePad = 8.0;
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
const gap = 8.0;
|
||||||
crossAxisCount: 2,
|
final cellW =
|
||||||
childAspectRatio: 0.8,
|
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||||
),
|
cols;
|
||||||
itemCount: list.length,
|
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||||
itemBuilder: (_, i) {
|
// ≈ 36) + slack. Artist line is suppressed in this
|
||||||
final AlbumRef album = list[i];
|
// grid (showArtist: false) since the page header already
|
||||||
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
|
// 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 '../api/errors.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
|
import '../models/system_playlists_status.dart';
|
||||||
import '../models/track.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/connection_error_banner.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
import 'widgets/artist_card.dart';
|
import 'widgets/artist_card.dart';
|
||||||
|
import 'widgets/compact_track_card.dart';
|
||||||
import 'widgets/horizontal_scroll_row.dart';
|
import 'widgets/horizontal_scroll_row.dart';
|
||||||
import 'widgets/track_row.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -22,99 +27,385 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||||
actions: [
|
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.library_music, color: fs.parchment),
|
|
||||||
tooltip: 'Library',
|
|
||||||
onPressed: () => context.push('/library'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
|
||||||
tooltip: 'Playlists',
|
|
||||||
onPressed: () => context.push('/playlists'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.search, color: fs.parchment),
|
|
||||||
tooltip: 'Search',
|
|
||||||
onPressed: () => context.push('/search'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.explore, color: fs.parchment),
|
|
||||||
tooltip: 'Discover',
|
|
||||||
onPressed: () => context.push('/discover'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.settings, color: fs.parchment),
|
|
||||||
tooltip: 'Settings',
|
|
||||||
onPressed: () => context.push('/settings'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ref.watch(homeProvider).when(
|
child: home.when(
|
||||||
error: (e, _) {
|
error: (e, _) {
|
||||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||||
if (code == 'connection_refused') {
|
if (code == 'connection_refused') {
|
||||||
return ConnectionErrorBanner(
|
return ConnectionErrorBanner(
|
||||||
onRetry: () => ref.refresh(homeProvider),
|
onRetry: () => ref.refresh(homeProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => _HomeSkeleton(fs: fs),
|
||||||
data: (h) => RefreshIndicator(
|
data: (h) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||||
child: ListView(children: [
|
child: ListView(
|
||||||
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
|
// ClampingScrollPhysics: no bounce overscroll past the
|
||||||
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
|
// content. Combined with the bottom padding below, this
|
||||||
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
|
// makes "scroll to end" land the last item just above the
|
||||||
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
|
// player bar — no empty void.
|
||||||
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
|
physics: const ClampingScrollPhysics(),
|
||||||
const SizedBox(height: 96),
|
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(
|
class _PlaylistsSection extends StatelessWidget {
|
||||||
title: title,
|
const _PlaylistsSection({required this.playlists, required this.status});
|
||||||
children: [
|
final List<Playlist> playlists;
|
||||||
for (final a in albums)
|
final SystemPlaylistsStatus status;
|
||||||
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
|
|
||||||
],
|
@override
|
||||||
);
|
Widget build(BuildContext context) {
|
||||||
|
final items = _buildPlaylistsRow(playlists, status);
|
||||||
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
|
return HorizontalScrollRow(
|
||||||
HorizontalScrollRow(
|
title: 'Playlists',
|
||||||
title: title,
|
height: 220,
|
||||||
children: [
|
children: items.map((item) {
|
||||||
for (final a in artists)
|
if (item is _RealPlaylist) {
|
||||||
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
|
return PlaylistCard(playlist: item.playlist);
|
||||||
],
|
}
|
||||||
);
|
final ph = item as _PlaceholderPlaylist;
|
||||||
|
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
||||||
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
|
}).toList(),
|
||||||
HorizontalScrollRow(
|
);
|
||||||
title: title,
|
}
|
||||||
height: 64,
|
}
|
||||||
children: [
|
|
||||||
for (final t in tracks)
|
abstract class _PlaylistRowItem {}
|
||||||
SizedBox(
|
class _RealPlaylist extends _PlaylistRowItem {
|
||||||
width: 280,
|
_RealPlaylist(this.playlist);
|
||||||
child: TrackRow(
|
final Playlist playlist;
|
||||||
track: t,
|
}
|
||||||
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
|
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)));
|
||||||
|
|
||||||
|
// Slots 2-4: 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: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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/client.dart';
|
import '../api/client.dart';
|
||||||
import '../api/endpoints/library.dart';
|
import '../api/endpoints/library.dart';
|
||||||
import '../auth/auth_provider.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/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/home_data.dart';
|
import '../models/home_data.dart';
|
||||||
@@ -37,24 +46,267 @@ final homeProvider = FutureProvider<HomeData>((ref) async {
|
|||||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
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 =
|
final artistProvider =
|
||||||
FutureProvider.family<ArtistRef, String>((ref, id) async {
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtist(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 =
|
final artistAlbumsProvider =
|
||||||
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
|
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
|
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)),
|
||||||
|
tag: 'artistAlbums($artistId)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final artistTracksProvider =
|
final artistTracksProvider =
|
||||||
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
|
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
|
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 =
|
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
||||||
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
|
/// for reactive updates over both rows.
|
||||||
(ref, id) async {
|
final albumProvider = StreamProvider.family<
|
||||||
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
|
({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);
|
||||||
|
debugPrint('albumProvider($albumId): calling getAlbum');
|
||||||
|
final fresh = await api
|
||||||
|
.getAlbum(albumId)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
|
||||||
|
// 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());
|
||||||
|
});
|
||||||
|
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
|
||||||
|
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) {
|
||||||
|
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
|
||||||
|
if (fetchAttempted) {
|
||||||
|
// Already tried and got nothing back.
|
||||||
|
yield (
|
||||||
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
|
tracks: const <TrackRef>[],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fetchAttempted = true;
|
||||||
|
final online = await isOnline();
|
||||||
|
debugPrint('albumProvider($albumId): online=$online');
|
||||||
|
if (!online) {
|
||||||
|
debugPrint('albumProvider($albumId): offline, yielding empty');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
|
||||||
|
|
||||||
|
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) {
|
||||||
|
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../api/endpoints/library_lists.dart';
|
import '../api/endpoints/library_lists.dart';
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../api/endpoints/me.dart';
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../cache/metadata_prefetcher.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
@@ -15,6 +16,7 @@ import '../models/page.dart' as wire;
|
|||||||
import '../models/quarantine_mine.dart';
|
import '../models/quarantine_mine.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
import 'widgets/artist_card.dart';
|
import 'widgets/artist_card.dart';
|
||||||
@@ -36,13 +38,89 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
return MeApi(await ref.watch(dioProvider.future));
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
/// `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;
|
||||||
|
|
||||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
@override
|
||||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
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 {
|
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||||
return (await ref.watch(_meApiProvider.future)).history();
|
return (await ref.watch(_meApiProvider.future)).history();
|
||||||
@@ -90,6 +168,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/library')],
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
controller: _ctrl,
|
controller: _ctrl,
|
||||||
isScrollable: true,
|
isScrollable: true,
|
||||||
@@ -129,25 +208,53 @@ class _ArtistsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryArtistsProvider).when(
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) => page.items.isEmpty
|
data: (page) {
|
||||||
? Center(child: Text('No artists.', style: TextStyle(color: fs.ash)))
|
// 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(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
onRefresh: () async {
|
||||||
child: GridView.builder(
|
ref.invalidate(_libraryArtistsProvider);
|
||||||
padding: const EdgeInsets.all(8),
|
await ref.read(_libraryArtistsProvider.future);
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
},
|
||||||
crossAxisCount: 3,
|
// Listen for scroll-near-bottom and ask the notifier
|
||||||
mainAxisSpacing: 8,
|
// to fetch the next page. 800px lookahead so the next
|
||||||
crossAxisSpacing: 8,
|
// page lands before the user reaches the visible end.
|
||||||
childAspectRatio: 0.78,
|
child: NotificationListener<ScrollNotification>(
|
||||||
),
|
onNotification: (n) {
|
||||||
itemCount: page.items.length,
|
if (n.metrics.pixels >=
|
||||||
itemBuilder: (ctx, i) => ArtistCard(
|
n.metrics.maxScrollExtent - 800) {
|
||||||
artist: page.items[i],
|
ref
|
||||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
.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),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,25 +268,66 @@ class _AlbumsTab extends ConsumerWidget {
|
|||||||
return ref.watch(_libraryAlbumsProvider).when(
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
data: (page) => page.items.isEmpty
|
data: (page) {
|
||||||
? Center(child: Text('No albums.', style: TextStyle(color: fs.ash)))
|
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(
|
: RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
onRefresh: () async {
|
||||||
child: GridView.builder(
|
ref.invalidate(_libraryAlbumsProvider);
|
||||||
padding: const EdgeInsets.all(8),
|
await ref.read(_libraryAlbumsProvider.future);
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
},
|
||||||
crossAxisCount: 2,
|
// Same responsive 3-up grid as the artist detail
|
||||||
mainAxisSpacing: 8,
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
crossAxisSpacing: 8,
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
childAspectRatio: 0.78,
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
),
|
const cols = 3;
|
||||||
itemCount: page.items.length,
|
const sidePad = 8.0;
|
||||||
itemBuilder: (ctx, i) => AlbumCard(
|
const gap = 8.0;
|
||||||
album: page.items[i],
|
final cellW = (constraints.maxWidth -
|
||||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,7 +387,7 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||||
}
|
}
|
||||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
||||||
return Center(child: Text('Nothing liked yet.', style: TextStyle(color: fs.ash)));
|
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
@@ -258,10 +406,14 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: ar.items.length,
|
itemCount: ar.items.length,
|
||||||
itemBuilder: (ctx, i) => ArtistCard(
|
itemBuilder: (ctx, i) {
|
||||||
artist: ar.items[i],
|
final artist = ar.items[i];
|
||||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
return ArtistCard(
|
||||||
),
|
artist: artist,
|
||||||
|
onTap: () =>
|
||||||
|
ctx.push('/artists/${artist.id}', extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -273,10 +425,14 @@ class _LikedTab extends ConsumerWidget {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: al.items.length,
|
itemCount: al.items.length,
|
||||||
itemBuilder: (ctx, i) => AlbumCard(
|
itemBuilder: (ctx, i) {
|
||||||
album: al.items[i],
|
final album = al.items[i];
|
||||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
return AlbumCard(
|
||||||
),
|
album: album,
|
||||||
|
onTap: () =>
|
||||||
|
ctx.push('/albums/${album.id}', extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,48 +2,80 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
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 AlbumRef album;
|
||||||
final VoidCallback onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return GestureDetector(
|
final coverSize = width - 16;
|
||||||
onTap: onTap,
|
return SizedBox(
|
||||||
child: SizedBox(
|
width: width,
|
||||||
width: 140,
|
child: Material(
|
||||||
child: Padding(
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
child: InkWell(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
onTap: onTap,
|
||||||
ClipRRect(
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(6),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Container(
|
child: Column(
|
||||||
width: 124,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
height: 124,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: fs.slate,
|
children: [
|
||||||
child: album.coverUrl.isEmpty
|
ClipRRect(
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
borderRadius: BorderRadius.circular(6),
|
||||||
: Image.network(album.coverUrl, fit: BoxFit.cover),
|
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 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatelessWidget {
|
||||||
@@ -12,31 +13,35 @@ class ArtistCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return GestureDetector(
|
return SizedBox(
|
||||||
onTap: onTap,
|
width: 140,
|
||||||
child: SizedBox(
|
child: Material(
|
||||||
width: 140,
|
color: Colors.transparent,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
onTap: onTap,
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Padding(
|
||||||
ClipOval(
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Container(
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
width: 124,
|
ClipOval(
|
||||||
height: 124,
|
child: Container(
|
||||||
color: fs.slate,
|
width: 124,
|
||||||
child: artist.coverUrl.isEmpty
|
height: 124,
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
color: fs.slate,
|
||||||
: Image.network(artist.coverUrl, fit: BoxFit.cover),
|
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),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
artist.name,
|
||||||
artist.name,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
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) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Padding(
|
if (title.isNotEmpty)
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
Padding(
|
||||||
child: Text(
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
title,
|
child: Text(
|
||||||
style: TextStyle(
|
title,
|
||||||
fontFamily: fs.display.fontFamily,
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontFamily: fs.display.fontFamily,
|
||||||
color: fs.parchment,
|
fontSize: 18,
|
||||||
|
color: fs.parchment,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: height,
|
height: height,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/track.dart';
|
import '../../models/track.dart';
|
||||||
|
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import 'cached_indicator.dart';
|
||||||
|
|
||||||
class TrackRow extends StatelessWidget {
|
class TrackRow extends StatelessWidget {
|
||||||
const TrackRow({
|
const TrackRow({
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.actions = true,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final Widget? trailing;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
@@ -22,38 +30,48 @@ class TrackRow extends StatelessWidget {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
if (track.trackNumber != null)
|
if (track.trackNumber != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 28,
|
width: 22,
|
||||||
child: Text(
|
child: Text(
|
||||||
track.trackNumber.toString(),
|
track.trackNumber.toString(),
|
||||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
track.title,
|
mainAxisSize: MainAxisSize.min,
|
||||||
maxLines: 1,
|
children: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Text(
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
track.title,
|
||||||
),
|
maxLines: 1,
|
||||||
Text(
|
overflow: TextOverflow.ellipsis,
|
||||||
track.artistName,
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
maxLines: 1,
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
// Skip the artist line entirely when empty so the row
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
// 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)),
|
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
if (trailing != null)
|
if (trailing != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: trailing!,
|
child: trailing!,
|
||||||
),
|
),
|
||||||
|
if (actions) TrackActionsButton(track: track),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class LikeButton extends ConsumerWidget {
|
|||||||
color: liked ? fs.accent : fs.ash,
|
color: liked ? fs.accent : fs.ash,
|
||||||
size: size,
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.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';
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
@@ -22,67 +28,154 @@ class LikedIds {
|
|||||||
LikeKind.album => albums.contains(id),
|
LikeKind.album => albums.contains(id),
|
||||||
LikeKind.track => tracks.contains(id),
|
LikeKind.track => tracks.contains(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
|
||||||
}
|
}
|
||||||
|
|
||||||
class LikedIdsController extends AsyncNotifier<LikedIds> {
|
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
|
||||||
@override
|
/// SyncController). Reactive — sync writes propagate via watch().
|
||||||
Future<LikedIds> build() async {
|
/// Empty + online triggers REST cold-cache fallback that re-populates.
|
||||||
final api = await ref.watch(likesApiProvider.future);
|
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
|
||||||
final r = await api.ids();
|
final db = ref.watch(appDbProvider);
|
||||||
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
|
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 {
|
Future<void> toggle(LikeKind kind, String id) async {
|
||||||
final api = await ref.read(likesApiProvider.future);
|
final user = _ref.read(authControllerProvider).value;
|
||||||
final current = state.value ??
|
if (user == null) return;
|
||||||
const LikedIds(artists: {}, albums: {}, tracks: {});
|
final db = _ref.read(appDbProvider);
|
||||||
|
final entityType = _entityType(kind);
|
||||||
|
|
||||||
final isLiked = current.has(kind, id);
|
final existing = await (db.select(db.cachedLikes)
|
||||||
final optimistic = _flip(current, kind, id);
|
..where((t) =>
|
||||||
state = AsyncData(optimistic);
|
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 {
|
try {
|
||||||
if (isLiked) {
|
final api = await _ref.read(likesApiProvider.future);
|
||||||
|
if (wasLiked) {
|
||||||
await api.unlike(kind, id);
|
await api.unlike(kind, id);
|
||||||
} else {
|
} else {
|
||||||
await api.like(kind, id);
|
await api.like(kind, id);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} 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);
|
Error.throwWithStackTrace(e, st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
|
String _entityType(LikeKind kind) => switch (kind) {
|
||||||
Set<String> swap(Set<String> s) {
|
LikeKind.artist => 'artist',
|
||||||
final next = {...s};
|
LikeKind.album => 'album',
|
||||||
if (s.contains(id)) {
|
LikeKind.track => 'track',
|
||||||
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),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final likedIdsProvider =
|
final likesControllerProvider =
|
||||||
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
|
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,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,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,20 +1,92 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'dart:async';
|
||||||
import 'package:just_audio/just_audio.dart';
|
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 '../models/track.dart';
|
||||||
|
import 'album_cover_cache.dart';
|
||||||
|
|
||||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||||
MinstrelAudioHandler() {
|
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()));
|
||||||
|
// Diagnostic: log every player-state transition so silent stops
|
||||||
|
// surface as something we can read in the log.
|
||||||
|
_player.playerStateStream.listen((s) {
|
||||||
|
debugPrint(
|
||||||
|
'audio_handler: state playing=${s.playing} processing=${s.processingState}');
|
||||||
|
});
|
||||||
|
_player.processingStateStream.listen((ps) {
|
||||||
|
debugPrint('audio_handler: processingState=$ps');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
String _baseUrl = '';
|
String _baseUrl = '';
|
||||||
String? _token;
|
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;
|
||||||
|
|
||||||
|
/// 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;
|
_baseUrl = baseUrl;
|
||||||
_token = token;
|
_token = token;
|
||||||
|
if (coverCache != null) _coverCache = coverCache;
|
||||||
|
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||||
|
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
||||||
|
'tokenPresent=${token != null && token.isNotEmpty} '
|
||||||
|
'coverCachePresent=${_coverCache != null} '
|
||||||
|
'audioCachePresent=${_audioCacheManager != null}');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
@@ -24,29 +96,199 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
debugPrint('audio_handler.setQueueFromTracks: '
|
||||||
final sources = tracks.map((t) {
|
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||||
final url = t.streamUrl.isNotEmpty
|
final sw = Stopwatch()..start();
|
||||||
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
|
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
||||||
? t.streamUrl
|
debugPrint('audio_handler: built ${sources.length} sources in '
|
||||||
: '$_baseUrl${t.streamUrl}')
|
'${sw.elapsedMilliseconds}ms');
|
||||||
: '$_baseUrl/api/tracks/${t.id}/stream';
|
sw.reset();
|
||||||
return AudioSource.uri(Uri.parse(url), headers: headers);
|
sw.start();
|
||||||
}).toList();
|
|
||||||
|
|
||||||
await _player.setAudioSources(
|
await _player.setAudioSources(
|
||||||
sources,
|
sources,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
);
|
);
|
||||||
|
debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms');
|
||||||
|
|
||||||
|
// Kick the cover fetch for the initial item — async, doesn't block
|
||||||
|
// playback. Subsequent track changes are handled by the
|
||||||
|
// currentIndexStream listener.
|
||||||
|
unawaited(_loadArtForCurrentItem());
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
String _resolveStreamUrl(TrackRef t) {
|
||||||
id: t.id,
|
if (t.streamUrl.isEmpty) {
|
||||||
title: t.title,
|
return '$_baseUrl/api/tracks/${t.id}/stream';
|
||||||
artist: t.artistName,
|
}
|
||||||
album: t.albumTitle,
|
final parsed = Uri.tryParse(t.streamUrl);
|
||||||
duration: Duration(seconds: t.durationSec),
|
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) {
|
||||||
|
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
|
||||||
|
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');
|
||||||
|
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
||||||
|
'using LockCachingAudioSource → ${cacheFile.path}');
|
||||||
|
// ignore: experimental_member_use
|
||||||
|
return LockCachingAudioSource(parsed,
|
||||||
|
headers: headers, cacheFile: cacheFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No manager configured: plain network source (legacy path).
|
||||||
|
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
debugPrint(
|
||||||
|
'audio_handler: registered stream cache for $trackId ($size bytes)');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
|
if (idx == null) 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
|
@override
|
||||||
Future<void> play() => _player.play();
|
Future<void> play() => _player.play();
|
||||||
@@ -63,7 +305,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
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;
|
final playing = _player.playing;
|
||||||
playbackState.add(PlaybackState(
|
playbackState.add(PlaybackState(
|
||||||
controls: [
|
controls: [
|
||||||
@@ -71,7 +340,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
if (playing) MediaControl.pause else MediaControl.play,
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
MediaControl.skipToNext,
|
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: switch (_player.processingState) {
|
||||||
ProcessingState.idle => AudioProcessingState.idle,
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
ProcessingState.loading => AudioProcessingState.loading,
|
ProcessingState.loading => AudioProcessingState.loading,
|
||||||
@@ -83,7 +365,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
updatePosition: _player.position,
|
updatePosition: _player.position,
|
||||||
bufferedPosition: _player.bufferedPosition,
|
bufferedPosition: _player.bufferedPosition,
|
||||||
speed: _player.speed,
|
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,15 +1,59 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 '../theme/theme_extension.dart';
|
||||||
import 'player_provider.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});
|
const NowPlayingScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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 fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
@@ -18,66 +62,343 @@ class NowPlayingScreen extends ConsumerWidget {
|
|||||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
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 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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
appBar: AppBar(
|
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||||
backgroundColor: fs.obsidian,
|
// and the seek slider are still tappable since gesture arena
|
||||||
leading: IconButton(
|
// gives priority to their child gesture detectors.
|
||||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
body: GestureDetector(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onVerticalDragStart: _onDragStart,
|
||||||
),
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
actions: [
|
onVerticalDragEnd: _onDragEnd,
|
||||||
IconButton(
|
behavior: HitTestBehavior.translucent,
|
||||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
child: SafeArea(
|
||||||
tooltip: 'Queue',
|
child: Column(
|
||||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
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(),
|
|
||||||
),
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,53 +1,295 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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 '../theme/theme_extension.dart';
|
||||||
import 'player_provider.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 {
|
class PlayerBar extends ConsumerWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final mediaItem = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
if (mediaItem == null) return const SizedBox.shrink();
|
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(
|
return Material(
|
||||||
color: fs.iron,
|
color: fs.iron,
|
||||||
child: InkWell(
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => context.push('/now-playing'),
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(children: [
|
child: Column(
|
||||||
Container(width: 48, height: 48, color: fs.slate),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(width: 12),
|
children: [
|
||||||
Expanded(child: Column(
|
IntrinsicHeight(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
Expanded(child: _TrackInfo(media: media)),
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
const SizedBox(width: 8),
|
||||||
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
|
_PlayControls(playback: playback, ref: ref),
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
],
|
||||||
],
|
),
|
||||||
)),
|
),
|
||||||
IconButton(
|
const SizedBox(height: 4),
|
||||||
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
|
_SeekRow(position: pos, duration: dur),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,25 @@
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/radio.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
|
import 'album_cover_cache.dart';
|
||||||
import 'audio_handler.dart';
|
import 'audio_handler.dart';
|
||||||
|
|
||||||
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
||||||
throw UnimplementedError('overridden in main()');
|
throw UnimplementedError('overridden in main()');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final albumCoverCacheProvider = Provider<AlbumCoverCache>((ref) {
|
||||||
|
return AlbumCoverCache(
|
||||||
|
dioFactory: () => ref.read(dioProvider.future),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
final playbackStateProvider = StreamProvider<PlaybackState>(
|
final playbackStateProvider = StreamProvider<PlaybackState>(
|
||||||
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
||||||
);
|
);
|
||||||
@@ -21,16 +32,103 @@ final queueProvider = StreamProvider<List<MediaItem>>(
|
|||||||
(ref) => ref.watch(audioHandlerProvider).queue,
|
(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 {
|
class PlayerActions {
|
||||||
PlayerActions(this._ref);
|
PlayerActions(this._ref);
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
|
// Stage-by-stage timing so we can see exactly where the
|
||||||
|
// "tap → audio" delay lives. Look for "playTracks: ..." lines
|
||||||
|
// in the next log capture; each stage label is followed by its
|
||||||
|
// elapsed millis since the previous stage.
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
int lap() {
|
||||||
|
final ms = sw.elapsedMilliseconds;
|
||||||
|
sw.reset();
|
||||||
|
sw.start();
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
final url = await _ref.read(serverUrlProvider.future);
|
final url = await _ref.read(serverUrlProvider.future);
|
||||||
|
debugPrint('playTracks: serverUrl ${lap()}ms');
|
||||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||||
final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token);
|
debugPrint('playTracks: token ${lap()}ms');
|
||||||
|
final cache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||||
|
final h = _ref.read(audioHandlerProvider)
|
||||||
|
..configure(
|
||||||
|
baseUrl: url ?? '',
|
||||||
|
token: token,
|
||||||
|
coverCache: cache,
|
||||||
|
audioCacheManager: audioCache,
|
||||||
|
);
|
||||||
|
debugPrint('playTracks: configure ${lap()}ms');
|
||||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||||
|
debugPrint('playTracks: setQueue (${tracks.length} tracks) ${lap()}ms');
|
||||||
await h.play();
|
await h.play();
|
||||||
|
debugPrint('playTracks: play() returned ${lap()}ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../cache/audio_cache_manager.dart';
|
||||||
|
import '../cache/db.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ class _Body extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final tracks = detail.tracks;
|
final tracks = detail.tracks;
|
||||||
final playable = tracks.where((t) => t.isAvailable).toList();
|
final playable = tracks.where((t) => t.isAvailable).toList();
|
||||||
|
|
||||||
@@ -57,9 +61,22 @@ class _Body extends ConsumerWidget {
|
|||||||
onRefresh: () async =>
|
onRefresh: () async =>
|
||||||
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
|
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: tracks.length + 1, // header + rows
|
// Header + (track rows | empty hint).
|
||||||
|
itemCount: tracks.isEmpty ? 2 : tracks.length + 1,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
if (i == 0) return _Header(detail: detail, playable: playable);
|
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];
|
final t = tracks[i - 1];
|
||||||
return _PlaylistTrackRow(
|
return _PlaylistTrackRow(
|
||||||
row: t,
|
row: t,
|
||||||
@@ -123,7 +140,26 @@ class _Header extends ConsumerWidget {
|
|||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (playable.isNotEmpty)
|
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(
|
FilledButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||||
@@ -136,6 +172,7 @@ class _Header extends ConsumerWidget {
|
|||||||
foregroundColor: fs.parchment,
|
foregroundColor: fs.parchment,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -184,6 +221,8 @@ class _PlaylistTrackRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text('$mins:$secs', 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)),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../models/playlist.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 '../theme/theme_extension.dart';
|
||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
@@ -22,13 +24,15 @@ class PlaylistsListScreen extends ConsumerWidget {
|
|||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
|
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/playlists')],
|
||||||
),
|
),
|
||||||
body: list.when(
|
body: list.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(
|
error: (e, _) => Center(
|
||||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||||
),
|
),
|
||||||
data: (items) {
|
data: (lists) {
|
||||||
|
final items = lists.all;
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -79,7 +83,11 @@ class _PlaylistTile extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: playlist.coverUrl.isEmpty
|
child: playlist.coverUrl.isEmpty
|
||||||
? Icon(Icons.queue_music, color: fs.ash)
|
? Icon(Icons.queue_music, color: fs.ash)
|
||||||
: Image.network(playlist.coverUrl, fit: BoxFit.cover),
|
: ServerImage(
|
||||||
|
url: playlist.coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -1,19 +1,394 @@
|
|||||||
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/me.dart';
|
||||||
import '../api/endpoints/playlists.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 '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
|
import '../models/system_playlists_status.dart';
|
||||||
|
|
||||||
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
||||||
return PlaylistsApi(await ref.watch(dioProvider.future));
|
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 =
|
final playlistsListProvider =
|
||||||
FutureProvider.family<List<Playlist>, String>((ref, kind) async {
|
StreamProvider.family<PlaylistsList, String>((ref, kind) {
|
||||||
return (await ref.watch(playlistsApiProvider.future)).list(kind: 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);
|
||||||
|
final ownedSysCount =
|
||||||
|
fresh.owned.where((p) => p.systemVariant != null).length;
|
||||||
|
debugPrint(
|
||||||
|
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
|
||||||
|
'(system=$ownedSysCount) public=${fresh.public.length}');
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
final ownedSys = owned.where((p) => p.isSystem).length;
|
||||||
|
final driftSys = filtered.where((r) => r.systemVariant != null).length;
|
||||||
|
debugPrint(
|
||||||
|
'playlistsListProvider($kind): drift rows=${rows.length} '
|
||||||
|
'filtered=${filtered.length} (system=$driftSys) '
|
||||||
|
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
|
||||||
|
'userId=${user.id}');
|
||||||
|
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 =
|
final playlistDetailProvider =
|
||||||
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
|
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
|
||||||
return (await ref.watch(playlistsApiProvider.future)).get(id);
|
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);
|
||||||
|
debugPrint('playlistDetailProvider($id): calling get');
|
||||||
|
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing');
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): drift write done; awaiting watch re-emit');
|
||||||
|
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) {
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): server says 404, evicting stale drift rows');
|
||||||
|
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) {
|
||||||
|
debugPrint('playlistDetailProvider($id): drift miss, cold-fetching');
|
||||||
|
if (fetchAttempted) {
|
||||||
|
yield emptyDetail();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fetchAttempted = true;
|
||||||
|
if (!await isOnline()) {
|
||||||
|
yield emptyDetail();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final ok = await fetchAndPopulate();
|
||||||
|
if (!ok) yield emptyDetail();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)');
|
||||||
|
|
||||||
|
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) {
|
||||||
|
debugPrint(
|
||||||
|
'playlistDetailProvider($id): playlist hit but no tracks; fetching');
|
||||||
|
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,78 @@
|
|||||||
|
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}'),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import '../library/widgets/artist_card.dart';
|
|||||||
import '../library/widgets/track_row.dart';
|
import '../library/widgets/track_row.dart';
|
||||||
import '../models/search_response.dart';
|
import '../models/search_response.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'search_provider.dart';
|
import 'search_provider.dart';
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
_focus.requestFocus();
|
_focus.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const MainAppBarActions(currentRoute: '/search'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: results.when(
|
body: results.when(
|
||||||
@@ -86,7 +88,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
data: (r) => r == null
|
data: (r) => r == null
|
||||||
? const _Hint(message: 'Type to search your library.')
|
? const _Hint(message: 'Type to search your library.')
|
||||||
: r.isEmpty
|
: r.isEmpty
|
||||||
? const _Hint(message: 'No matches.')
|
? const _Hint(message: 'No matches for that query.')
|
||||||
: _Results(results: r),
|
: _Results(results: r),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import '../api/endpoints/settings.dart';
|
|||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
import '../library/library_providers.dart' show dioProvider;
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/my_profile.dart';
|
import '../models/my_profile.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'storage_section.dart';
|
||||||
|
import '../theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
||||||
return SettingsApi(await ref.watch(dioProvider.future));
|
return SettingsApi(await ref.watch(dioProvider.future));
|
||||||
@@ -37,15 +40,23 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text('Settings', style: TextStyle(color: fs.parchment)),
|
title: Text('Settings', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/settings')],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: const [
|
children: const [
|
||||||
_ProfileSection(),
|
_ProfileSection(),
|
||||||
_Divider(),
|
_Divider(),
|
||||||
|
_RequestsSection(),
|
||||||
|
_Divider(),
|
||||||
|
_AppearanceSection(),
|
||||||
|
_Divider(),
|
||||||
|
StorageSection(),
|
||||||
|
_Divider(),
|
||||||
_PasswordSection(),
|
_PasswordSection(),
|
||||||
_Divider(),
|
_Divider(),
|
||||||
_ListenBrainzSection(),
|
_ListenBrainzSection(),
|
||||||
|
_AdminSection(),
|
||||||
SizedBox(height: 96),
|
SizedBox(height: 96),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -85,6 +96,71 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _ProfileSection extends ConsumerStatefulWidget {
|
||||||
const _ProfileSection();
|
const _ProfileSection();
|
||||||
@override
|
@override
|
||||||
@@ -451,3 +527,45 @@ InputDecoration _inputDecoration(FabledSwordTheme fs, String label) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,6 +7,8 @@ import '../auth/login_screen.dart';
|
|||||||
import '../auth/server_url_screen.dart';
|
import '../auth/server_url_screen.dart';
|
||||||
import '../library/album_detail_screen.dart';
|
import '../library/album_detail_screen.dart';
|
||||||
import '../library/artist_detail_screen.dart';
|
import '../library/artist_detail_screen.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../discover/discover_screen.dart';
|
import '../discover/discover_screen.dart';
|
||||||
import '../library/home_screen.dart';
|
import '../library/home_screen.dart';
|
||||||
import '../library/library_screen.dart';
|
import '../library/library_screen.dart';
|
||||||
@@ -15,8 +17,15 @@ import '../player/player_bar.dart';
|
|||||||
import '../player/queue_screen.dart';
|
import '../player/queue_screen.dart';
|
||||||
import '../playlists/playlist_detail_screen.dart';
|
import '../playlists/playlist_detail_screen.dart';
|
||||||
import '../playlists/playlists_list_screen.dart';
|
import '../playlists/playlists_list_screen.dart';
|
||||||
|
import '../requests/requests_screen.dart';
|
||||||
import '../search/search_screen.dart';
|
import '../search/search_screen.dart';
|
||||||
import '../settings/settings_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';
|
import 'widgets/version_gate.dart';
|
||||||
|
|
||||||
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
||||||
@@ -39,26 +48,71 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
||||||
return '/home';
|
return '/home';
|
||||||
}
|
}
|
||||||
|
if (loc.startsWith('/admin') && !user!.isAdmin) {
|
||||||
|
return '/home';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
||||||
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
||||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
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(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/artists/:id',
|
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(
|
GoRoute(
|
||||||
path: '/albums/:id',
|
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: '/queue', builder: (_, __) => const QueueScreen()),
|
|
||||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||||
@@ -68,20 +122,41 @@ GoRouter buildRouter(Ref ref) {
|
|||||||
path: '/playlists/:id',
|
path: '/playlists/:id',
|
||||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
|
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});
|
const _ShellWithPlayerBar({required this.child});
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@override
|
@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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: child),
|
const UpdateBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: hasBanner
|
||||||
|
? MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
: child,
|
||||||
|
),
|
||||||
const PlayerBar(),
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../playlists/playlists_provider.dart';
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet listing the caller's user-created playlists.
|
||||||
|
/// Returns the picked playlistId via Navigator.pop, or null on cancel.
|
||||||
|
class AddToPlaylistSheet extends ConsumerWidget {
|
||||||
|
const AddToPlaylistSheet({super.key});
|
||||||
|
|
||||||
|
static Future<String?> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => const AddToPlaylistSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final lists = ref.watch(playlistsListProvider('user'));
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
color: fs.iron,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
|
child: Text(
|
||||||
|
'Add to playlist',
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontFamily: 'Fraunces',
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: lists.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||||
|
),
|
||||||
|
data: (data) {
|
||||||
|
final owned = data.owned
|
||||||
|
.where((p) => p.systemVariant == null)
|
||||||
|
.toList();
|
||||||
|
if (owned.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Text(
|
||||||
|
"You haven't created any playlists yet.",
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: owned.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final p = owned[i];
|
||||||
|
return ListTile(
|
||||||
|
key: Key('add_to_playlist_${p.id}'),
|
||||||
|
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||||
|
title: Text(
|
||||||
|
p.name,
|
||||||
|
style: TextStyle(color: fs.parchment),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${p.trackCount} ${p.trackCount == 1 ? "track" : "tracks"}',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.pop(context, p.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet for picking a hide reason + optional notes.
|
||||||
|
/// Returns ({reason, notes}) on submit, null on cancel.
|
||||||
|
class HideTrackSheet extends StatefulWidget {
|
||||||
|
const HideTrackSheet({super.key});
|
||||||
|
|
||||||
|
static Future<({String reason, String notes})?> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet<({String reason, String notes})>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => const HideTrackSheet(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HideTrackSheet> createState() => _HideTrackSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HideTrackSheetState extends State<HideTrackSheet> {
|
||||||
|
String _reason = 'bad_rip';
|
||||||
|
final _notesCtrl = TextEditingController();
|
||||||
|
|
||||||
|
// Wire values mirror the server vocabulary; display labels mirror the
|
||||||
|
// existing _QuarantineTile in library_screen.dart.
|
||||||
|
static const _options = [
|
||||||
|
('bad_rip', 'Bad rip'),
|
||||||
|
('wrong_file', 'Wrong file'),
|
||||||
|
('wrong_tags', 'Wrong tags'),
|
||||||
|
('duplicate', 'Duplicate'),
|
||||||
|
('other', 'Other'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return SafeArea(
|
||||||
|
child: 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(
|
||||||
|
'Hide this track',
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontFamily: 'Fraunces',
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Pick a reason. Optional notes are visible to admins.',
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final (value, label) in _options)
|
||||||
|
ChoiceChip(
|
||||||
|
key: Key('hide_reason_$value'),
|
||||||
|
label: Text(label),
|
||||||
|
selected: _reason == value,
|
||||||
|
onSelected: (_) => setState(() => _reason = value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
key: const Key('hide_notes_input'),
|
||||||
|
controller: _notesCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes (optional)',
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
key: const Key('hide_confirm'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: fs.oxblood,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(
|
||||||
|
context,
|
||||||
|
(reason: _reason, notes: _notesCtrl.text.trim()),
|
||||||
|
),
|
||||||
|
child: const Text('Hide'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../models/track.dart';
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
import 'track_actions_sheet.dart';
|
||||||
|
|
||||||
|
/// Small 3-dot trigger that opens TrackActionsSheet. Drop into any
|
||||||
|
/// track row / card to expose the canonical 7-action menu.
|
||||||
|
class TrackActionsButton extends StatelessWidget {
|
||||||
|
const TrackActionsButton({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
this.hideQueueActions = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TrackRef track;
|
||||||
|
|
||||||
|
/// Suppresses Play next / Add to queue. Set true on the Now Playing
|
||||||
|
/// screen where the menu's track IS the currently-playing one.
|
||||||
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.more_vert, color: fs.ash, size: 18),
|
||||||
|
tooltip: 'Track actions',
|
||||||
|
onPressed: () => TrackActionsSheet.show(
|
||||||
|
context,
|
||||||
|
track,
|
||||||
|
hideQueueActions: hideQueueActions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../api/endpoints/likes.dart';
|
||||||
|
import '../../../likes/likes_provider.dart';
|
||||||
|
import '../../../models/track.dart';
|
||||||
|
import '../../../player/player_provider.dart';
|
||||||
|
import '../../../playlists/playlists_provider.dart';
|
||||||
|
import '../../../quarantine/quarantine_provider.dart';
|
||||||
|
import '../../../theme/theme_extension.dart';
|
||||||
|
import 'add_to_playlist_sheet.dart';
|
||||||
|
import 'hide_track_sheet.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet that lists the 7 canonical track actions.
|
||||||
|
/// Pops first when an item is tapped (immediate visual feedback) and
|
||||||
|
/// then runs the action — for actions that open a sub-sheet, the
|
||||||
|
/// sub-sheet opens after the parent pops.
|
||||||
|
class TrackActionsSheet extends ConsumerWidget {
|
||||||
|
const TrackActionsSheet({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
required this.hideQueueActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TrackRef track;
|
||||||
|
final bool hideQueueActions;
|
||||||
|
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context,
|
||||||
|
TrackRef track, {
|
||||||
|
bool hideQueueActions = false,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => TrackActionsSheet(
|
||||||
|
track: track,
|
||||||
|
hideQueueActions: hideQueueActions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final liked = ref.watch(likedIdsProvider).value?.has(LikeKind.track, track.id) ?? false;
|
||||||
|
final hidden = ref.watch(myQuarantineProvider).value?.any((r) => r.trackId == track.id) ?? false;
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
color: fs.iron,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!hideQueueActions) ...[
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_play_next'),
|
||||||
|
icon: Icons.playlist_play,
|
||||||
|
label: 'Play next',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(playerActionsProvider).playNext(track);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to queue (next)')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_enqueue'),
|
||||||
|
icon: Icons.queue_music,
|
||||||
|
label: 'Add to queue',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(playerActionsProvider).enqueue(track);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to queue')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
],
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_like'),
|
||||||
|
icon: liked ? Icons.favorite : Icons.favorite_border,
|
||||||
|
label: liked ? 'Unlike' : 'Like',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(likesControllerProvider).toggle(LikeKind.track, track.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_add_to_playlist'),
|
||||||
|
icon: Icons.playlist_add,
|
||||||
|
label: 'Add to playlist…',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final playlistId = await AddToPlaylistSheet.show(context);
|
||||||
|
if (playlistId == null || !context.mounted) return;
|
||||||
|
try {
|
||||||
|
await ref.read(addToPlaylistActionProvider).call(playlistId, track.id);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Added to playlist')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't add to playlist: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_start_radio'),
|
||||||
|
icon: Icons.radio,
|
||||||
|
label: 'Start radio',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
await ref.read(playerActionsProvider).startRadio(track.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't start radio: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_go_to_album'),
|
||||||
|
icon: Icons.album,
|
||||||
|
label: 'Go to album',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/albums/${track.albumId}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_go_to_artist'),
|
||||||
|
icon: Icons.person,
|
||||||
|
label: 'Go to artist',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.push('/artists/${track.artistId}');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const _Divider(),
|
||||||
|
_MenuItem(
|
||||||
|
key: const Key('track_actions_hide'),
|
||||||
|
icon: hidden ? Icons.visibility : Icons.visibility_off,
|
||||||
|
label: hidden ? 'Unhide' : 'Hide',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
if (hidden) {
|
||||||
|
try {
|
||||||
|
await ref.read(myQuarantineProvider.notifier).unflag(track.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't unhide: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await HideTrackSheet.show(context);
|
||||||
|
if (result == null || !context.mounted) return;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(myQuarantineProvider.notifier)
|
||||||
|
.flag(track, result.reason, result.notes);
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't hide: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuItem extends StatelessWidget {
|
||||||
|
const _MenuItem({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: fs.parchment),
|
||||||
|
title: Text(label, style: TextStyle(color: fs.parchment)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Divider extends StatelessWidget {
|
||||||
|
const _Divider();
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Divider(height: 1, color: fs.slate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience callable for "append a single track to a playlist."
|
||||||
|
/// Lives here (not in playlists_provider) because it's purely a
|
||||||
|
/// menu-flow concern — no other caller.
|
||||||
|
typedef AddToPlaylistAction = Future<void> Function(String playlistId, String trackId);
|
||||||
|
|
||||||
|
final addToPlaylistActionProvider = Provider<AddToPlaylistAction>((ref) {
|
||||||
|
return (playlistId, trackId) async {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
await api.appendTracks(playlistId, [trackId]);
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -4,8 +4,8 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'theme_extension.dart';
|
import 'theme_extension.dart';
|
||||||
import 'tokens.dart';
|
import 'tokens.dart';
|
||||||
|
|
||||||
ThemeData buildThemeData() {
|
ThemeData buildDarkTheme() {
|
||||||
final fs = FabledSwordTheme.fromTokens();
|
final fs = FabledSwordTheme.dark();
|
||||||
final colorScheme = ColorScheme.dark(
|
final colorScheme = ColorScheme.dark(
|
||||||
surface: fs.iron,
|
surface: fs.iron,
|
||||||
onSurface: fs.parchment,
|
onSurface: fs.parchment,
|
||||||
@@ -14,16 +14,45 @@ ThemeData buildThemeData() {
|
|||||||
secondary: fs.moss,
|
secondary: fs.moss,
|
||||||
error: fs.error,
|
error: fs.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
scaffoldBackgroundColor: fs.obsidian,
|
scaffoldBackgroundColor: fs.obsidian,
|
||||||
textTheme: GoogleFonts.interTextTheme().apply(
|
textTheme: GoogleFonts.interTextTheme().apply(
|
||||||
bodyColor: fs.parchment,
|
bodyColor: fs.parchment,
|
||||||
displayColor: fs.parchment,
|
displayColor: fs.parchment,
|
||||||
),
|
),
|
||||||
fontFamily: FabledSwordTokens.fontBody,
|
fontFamily: FabledSwordFlatTokens.fontBody,
|
||||||
extensions: <ThemeExtension<dynamic>>[fs],
|
extensions: <ThemeExtension<dynamic>>[fs],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThemeData buildLightTheme() {
|
||||||
|
final fs = FabledSwordTheme.light();
|
||||||
|
final colorScheme = ColorScheme.light(
|
||||||
|
surface: fs.iron,
|
||||||
|
onSurface: fs.parchment, // semantic — light's "parchment" is dark text
|
||||||
|
primary: fs.accent,
|
||||||
|
onPrimary: FabledSwordFlatTokens.onAction,
|
||||||
|
secondary: fs.moss,
|
||||||
|
error: fs.error,
|
||||||
|
);
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
scaffoldBackgroundColor: fs.obsidian, // semantic — light's obsidian is the bg
|
||||||
|
textTheme: GoogleFonts.interTextTheme().apply(
|
||||||
|
bodyColor: fs.parchment,
|
||||||
|
displayColor: fs.parchment,
|
||||||
|
),
|
||||||
|
fontFamily: FabledSwordFlatTokens.fontBody,
|
||||||
|
extensions: <ThemeExtension<dynamic>>[fs],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat alias. Existing tests + code call buildThemeData(); it
|
||||||
|
/// returns the dark theme to preserve current behaviour. New code
|
||||||
|
/// should prefer buildDarkTheme()/buildLightTheme() explicitly.
|
||||||
|
ThemeData buildThemeData() => buildDarkTheme();
|
||||||
|
|||||||
@@ -30,26 +30,51 @@ class FabledSwordTheme extends ThemeExtension<FabledSwordTheme> {
|
|||||||
final Color warning, error, info;
|
final Color warning, error, info;
|
||||||
final TextStyle display, body, mono;
|
final TextStyle display, body, mono;
|
||||||
|
|
||||||
static FabledSwordTheme fromTokens() => const FabledSwordTheme(
|
factory FabledSwordTheme.dark() => const FabledSwordTheme(
|
||||||
accent: FabledSwordTokens.accent,
|
accent: FabledSwordFlatTokens.accent,
|
||||||
obsidian: FabledSwordTokens.obsidian,
|
obsidian: FabledSwordDarkTokens.obsidian,
|
||||||
iron: FabledSwordTokens.iron,
|
iron: FabledSwordDarkTokens.iron,
|
||||||
slate: FabledSwordTokens.slate,
|
slate: FabledSwordDarkTokens.slate,
|
||||||
pewter: FabledSwordTokens.pewter,
|
pewter: FabledSwordDarkTokens.pewter,
|
||||||
parchment: FabledSwordTokens.parchment,
|
parchment: FabledSwordDarkTokens.parchment,
|
||||||
vellum: FabledSwordTokens.vellum,
|
vellum: FabledSwordDarkTokens.vellum,
|
||||||
ash: FabledSwordTokens.ash,
|
ash: FabledSwordDarkTokens.ash,
|
||||||
moss: FabledSwordTokens.moss,
|
moss: FabledSwordFlatTokens.moss,
|
||||||
bronze: FabledSwordTokens.bronze,
|
bronze: FabledSwordFlatTokens.bronze,
|
||||||
oxblood: FabledSwordTokens.oxblood,
|
oxblood: FabledSwordFlatTokens.oxblood,
|
||||||
warning: FabledSwordTokens.warning,
|
warning: FabledSwordFlatTokens.warning,
|
||||||
error: FabledSwordTokens.error,
|
error: FabledSwordFlatTokens.error,
|
||||||
info: FabledSwordTokens.info,
|
info: FabledSwordFlatTokens.info,
|
||||||
display: TextStyle(fontFamily: FabledSwordTokens.fontDisplay),
|
display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay),
|
||||||
body: TextStyle(fontFamily: FabledSwordTokens.fontBody),
|
body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody),
|
||||||
mono: TextStyle(fontFamily: FabledSwordTokens.fontMono),
|
mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
factory FabledSwordTheme.light() => const FabledSwordTheme(
|
||||||
|
accent: FabledSwordFlatTokens.accent,
|
||||||
|
obsidian: FabledSwordLightTokens.obsidian,
|
||||||
|
iron: FabledSwordLightTokens.iron,
|
||||||
|
slate: FabledSwordLightTokens.slate,
|
||||||
|
pewter: FabledSwordLightTokens.pewter,
|
||||||
|
parchment: FabledSwordLightTokens.parchment,
|
||||||
|
vellum: FabledSwordLightTokens.vellum,
|
||||||
|
ash: FabledSwordLightTokens.ash,
|
||||||
|
moss: FabledSwordFlatTokens.moss,
|
||||||
|
bronze: FabledSwordFlatTokens.bronze,
|
||||||
|
oxblood: FabledSwordFlatTokens.oxblood,
|
||||||
|
warning: FabledSwordFlatTokens.warning,
|
||||||
|
error: FabledSwordFlatTokens.error,
|
||||||
|
info: FabledSwordFlatTokens.info,
|
||||||
|
display: TextStyle(fontFamily: FabledSwordFlatTokens.fontDisplay),
|
||||||
|
body: TextStyle(fontFamily: FabledSwordFlatTokens.fontBody),
|
||||||
|
mono: TextStyle(fontFamily: FabledSwordFlatTokens.fontMono),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Back-compat alias for the original API. Returns the dark theme.
|
||||||
|
/// Existing call sites can keep using fromTokens() until they're
|
||||||
|
/// migrated to .dark() / .light() explicitly.
|
||||||
|
static FabledSwordTheme fromTokens() => FabledSwordTheme.dark();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FabledSwordTheme copyWith({
|
FabledSwordTheme copyWith({
|
||||||
Color? accent,
|
Color? accent,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
|
||||||
|
const _kThemeModeKey = 'theme_mode';
|
||||||
|
|
||||||
|
enum AppThemeMode { system, dark, light }
|
||||||
|
|
||||||
|
extension AppThemeModeMaterial on AppThemeMode {
|
||||||
|
ThemeMode get materialMode => switch (this) {
|
||||||
|
AppThemeMode.system => ThemeMode.system,
|
||||||
|
AppThemeMode.dark => ThemeMode.dark,
|
||||||
|
AppThemeMode.light => ThemeMode.light,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThemeModeController extends AsyncNotifier<AppThemeMode> {
|
||||||
|
@override
|
||||||
|
Future<AppThemeMode> build() async {
|
||||||
|
final storage = ref.watch(secureStorageProvider);
|
||||||
|
final raw = await storage.read(key: _kThemeModeKey);
|
||||||
|
return switch (raw) {
|
||||||
|
'dark' => AppThemeMode.dark,
|
||||||
|
'light' => AppThemeMode.light,
|
||||||
|
_ => AppThemeMode.system,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> set(AppThemeMode mode) async {
|
||||||
|
final storage = ref.read(secureStorageProvider);
|
||||||
|
await storage.write(key: _kThemeModeKey, value: mode.name);
|
||||||
|
state = AsyncData(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final themeModeProvider =
|
||||||
|
AsyncNotifierProvider<ThemeModeController, AppThemeMode>(
|
||||||
|
ThemeModeController.new,
|
||||||
|
);
|
||||||
@@ -2,6 +2,46 @@
|
|||||||
// Run `dart run tool/gen_tokens.dart` to regenerate.
|
// Run `dart run tool/gen_tokens.dart` to regenerate.
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FabledSwordDarkTokens {
|
||||||
|
static const Color obsidian = Color(0xFF14171A);
|
||||||
|
static const Color iron = Color(0xFF1E2228);
|
||||||
|
static const Color slate = Color(0xFF2C313A);
|
||||||
|
static const Color pewter = Color(0xFF3F4651);
|
||||||
|
static const Color parchment = Color(0xFFE8E4D8);
|
||||||
|
static const Color vellum = Color(0xFFC2BFB4);
|
||||||
|
static const Color ash = Color(0xFF9C9A92);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FabledSwordLightTokens {
|
||||||
|
static const Color obsidian = Color(0xFFF8F5EE);
|
||||||
|
static const Color iron = Color(0xFFECE6D5);
|
||||||
|
static const Color slate = Color(0xFFDCD3BD);
|
||||||
|
static const Color pewter = Color(0xFFB8AE94);
|
||||||
|
static const Color parchment = Color(0xFF14171A);
|
||||||
|
static const Color vellum = Color(0xFF2C313A);
|
||||||
|
static const Color ash = Color(0xFF5C6068);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FabledSwordFlatTokens {
|
||||||
|
static const Color moss = Color(0xFF4A5D3F);
|
||||||
|
static const Color bronze = Color(0xFF8B7355);
|
||||||
|
static const Color oxblood = Color(0xFF6B2118);
|
||||||
|
static const Color warning = Color(0xFF8B6F1E);
|
||||||
|
static const Color error = Color(0xFFC04A1F);
|
||||||
|
static const Color info = Color(0xFF3D5A6E);
|
||||||
|
static const Color accent = Color(0xFF4A6B5C);
|
||||||
|
static const Color onAction = Color(0xFFE8E4D8);
|
||||||
|
static const double radiusSm = 4;
|
||||||
|
static const double radiusMd = 8;
|
||||||
|
static const double radiusLg = 12;
|
||||||
|
static const double radiusXl = 16;
|
||||||
|
static const String fontDisplay = "Fraunces";
|
||||||
|
static const String fontBody = "Inter";
|
||||||
|
static const String fontMono = "JetBrains Mono";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back-compat alias — dark surface tokens + flat. Prefer the explicit
|
||||||
|
/// FabledSwordDarkTokens / FabledSwordLightTokens / FabledSwordFlatTokens.
|
||||||
class FabledSwordTokens {
|
class FabledSwordTokens {
|
||||||
static const Color obsidian = Color(0xFF14171A);
|
static const Color obsidian = Color(0xFF14171A);
|
||||||
static const Color iron = Color(0xFF1E2228);
|
static const Color iron = Color(0xFF1E2228);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import 'installer.dart';
|
||||||
|
import 'update_info.dart';
|
||||||
|
|
||||||
|
/// Tracks the bundled-server APK version vs. the installed app version.
|
||||||
|
/// Polls /api/client/version on startup + every 24h. Returns null when
|
||||||
|
/// no update is available (or the channel is unreachable / disabled).
|
||||||
|
///
|
||||||
|
/// Server returns 404 when no APK is bundled (dev environments, pre-CI
|
||||||
|
/// images) — we treat that as "no update channel" and stay silent.
|
||||||
|
class ClientUpdateController extends AsyncNotifier<UpdateInfo?> {
|
||||||
|
static const Duration _pollInterval = Duration(hours: 24);
|
||||||
|
|
||||||
|
Timer? _pollTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UpdateInfo?> build() async {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
_pollTimer = null;
|
||||||
|
});
|
||||||
|
_pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf());
|
||||||
|
return _check();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UpdateInfo?> _check() async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final Response<Map<String, dynamic>> r;
|
||||||
|
try {
|
||||||
|
r = await dio.get<Map<String, dynamic>>('/api/client/version');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
// 404 = no APK bundled; any other error = treat as silent.
|
||||||
|
if (e.response?.statusCode == 404) return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (r.data == null) return null;
|
||||||
|
|
||||||
|
final info = UpdateInfo.fromJson(r.data!);
|
||||||
|
final installed = (await PackageInfo.fromPlatform()).version;
|
||||||
|
if (!isVersionNewer(info.version, installed)) return null;
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `serverVersion` is strictly newer than `installedVersion`.
|
||||||
|
///
|
||||||
|
/// Comparison strategy: split both strings on `.`, parse each component
|
||||||
|
/// as an int (non-numeric = 0), pad the shorter list with zeros, then
|
||||||
|
/// compare component-wise. This handles our date-style versions
|
||||||
|
/// (2026.05.10.1) which exceed the 3-part semver shape that
|
||||||
|
/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as
|
||||||
|
/// equal to "2026.05.10.0" rather than "different = newer".
|
||||||
|
///
|
||||||
|
/// Falls back to pub_semver as a secondary attempt when the components
|
||||||
|
/// look semver-like (handles pre-release suffixes, build metadata, etc).
|
||||||
|
///
|
||||||
|
/// Exposed for testing; the polling logic in ClientUpdateController
|
||||||
|
/// is the only production caller.
|
||||||
|
bool isVersionNewer(String serverVersion, String installedVersion) {
|
||||||
|
List<int> parts(String v) => v
|
||||||
|
.replaceFirst(RegExp(r'^v'), '')
|
||||||
|
.split('.')
|
||||||
|
.map((p) => int.tryParse(p) ?? 0)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final svr = parts(serverVersion);
|
||||||
|
final ins = parts(installedVersion);
|
||||||
|
|
||||||
|
// Safety net for non-numeric versions (e.g. branch-name builds like
|
||||||
|
// "main" vs "dev"): if neither side parsed any non-zero component,
|
||||||
|
// fall back to string inequality so an operator on a dev build
|
||||||
|
// still gets the banner instead of silently matching everything.
|
||||||
|
final svrAllZero = svr.every((c) => c == 0);
|
||||||
|
final insAllZero = ins.every((c) => c == 0);
|
||||||
|
if (svrAllZero && insAllZero) {
|
||||||
|
return serverVersion.replaceFirst(RegExp(r'^v'), '') !=
|
||||||
|
installedVersion.replaceFirst(RegExp(r'^v'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
final n = svr.length > ins.length ? svr.length : ins.length;
|
||||||
|
while (svr.length < n) {
|
||||||
|
svr.add(0);
|
||||||
|
}
|
||||||
|
while (ins.length < n) {
|
||||||
|
ins.add(0);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
if (svr[i] > ins[i]) return true;
|
||||||
|
if (svr[i] < ins[i]) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final clientUpdateProvider =
|
||||||
|
AsyncNotifierProvider<ClientUpdateController, UpdateInfo?>(
|
||||||
|
ClientUpdateController.new);
|
||||||
|
|
||||||
|
/// In-memory dismissed-versions set. Keyed by version string so a
|
||||||
|
/// later release's banner re-appears even if the operator dismissed
|
||||||
|
/// the previous one. Not persisted — restart re-shows the banner,
|
||||||
|
/// which is acceptable nudging for v1.
|
||||||
|
class _DismissedVersionsNotifier extends Notifier<Set<String>> {
|
||||||
|
@override
|
||||||
|
Set<String> build() => <String>{};
|
||||||
|
|
||||||
|
void add(String version) {
|
||||||
|
state = {...state, version};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _dismissedVersionsProvider =
|
||||||
|
NotifierProvider<_DismissedVersionsNotifier, Set<String>>(
|
||||||
|
_DismissedVersionsNotifier.new);
|
||||||
|
|
||||||
|
/// True when the update banner should render: an UpdateInfo is
|
||||||
|
/// available AND the operator hasn't dismissed this specific version.
|
||||||
|
final shouldShowUpdateBannerProvider = Provider<UpdateInfo?>((ref) {
|
||||||
|
final info = ref.watch(clientUpdateProvider).value;
|
||||||
|
if (info == null) return null;
|
||||||
|
final dismissed = ref.watch(_dismissedVersionsProvider);
|
||||||
|
if (dismissed.contains(info.version)) return null;
|
||||||
|
return info;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Dismiss controller: marks the given version as dismissed so the
|
||||||
|
/// banner stops showing for this session.
|
||||||
|
final dismissUpdateProvider = Provider<void Function(String)>((ref) {
|
||||||
|
return (version) =>
|
||||||
|
ref.read(_dismissedVersionsProvider.notifier).add(version);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Installer provider — depends on dio so the install download uses
|
||||||
|
/// the same authenticated client (though /api/client/apk is unauthed,
|
||||||
|
/// reusing dio keeps configuration consistent).
|
||||||
|
final updateInstallerProvider = FutureProvider<UpdateInstaller>((ref) async {
|
||||||
|
return UpdateInstaller(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Bridges to MainActivity.kt's MethodChannel for the Android
|
||||||
|
/// PackageInstaller intent (#397). Web / iOS don't support self-
|
||||||
|
/// install; this class is Android-only at v1.
|
||||||
|
class UpdateInstaller {
|
||||||
|
UpdateInstaller(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('com.fabledsword.minstrel/installer');
|
||||||
|
static const _filename = 'minstrel-update.apk';
|
||||||
|
|
||||||
|
/// Streams the APK from `apkUrl` into the cache directory. `onProgress`
|
||||||
|
/// receives 0..1 fractions; emits 1.0 once when the download completes.
|
||||||
|
/// Returns the local path the install intent will read from.
|
||||||
|
Future<String> download(
|
||||||
|
String apkUrl, {
|
||||||
|
void Function(double progress)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final cacheDir = await getApplicationCacheDirectory();
|
||||||
|
final path = '${cacheDir.path}/$_filename';
|
||||||
|
await _dio.download(
|
||||||
|
apkUrl,
|
||||||
|
path,
|
||||||
|
onReceiveProgress: (received, total) {
|
||||||
|
if (total > 0 && onProgress != null) {
|
||||||
|
onProgress(received / total);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands the downloaded APK to Android's PackageInstaller via a
|
||||||
|
/// FileProvider content:// URI. The system shows the install confirm
|
||||||
|
/// dialog; user must tap Install. App restarts on the new version.
|
||||||
|
///
|
||||||
|
/// First-ever install attempt prompts the user to flip "Install
|
||||||
|
/// unknown apps" for Minstrel in Settings → Apps → Special access.
|
||||||
|
/// One-time grant; persists across updates.
|
||||||
|
Future<void> install(String apkPath) async {
|
||||||
|
await _channel.invokeMethod('install', {'path': apkPath});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'client_update_provider.dart';
|
||||||
|
import 'update_info.dart';
|
||||||
|
|
||||||
|
/// Soft banner mounted at the top of the shell. Renders nothing when
|
||||||
|
/// no update is available or the user has dismissed this version's
|
||||||
|
/// banner. Tapping Install downloads the APK and fires the system
|
||||||
|
/// install intent (Android only).
|
||||||
|
class UpdateBanner extends ConsumerStatefulWidget {
|
||||||
|
const UpdateBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<UpdateBanner> createState() => _UpdateBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _Stage { idle, downloading, error }
|
||||||
|
|
||||||
|
class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||||
|
_Stage _stage = _Stage.idle;
|
||||||
|
double _progress = 0;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _onInstall(UpdateInfo info) async {
|
||||||
|
setState(() {
|
||||||
|
_stage = _Stage.downloading;
|
||||||
|
_progress = 0;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final installer = await ref.read(updateInstallerProvider.future);
|
||||||
|
final path = await installer.download(
|
||||||
|
info.apkUrl,
|
||||||
|
onProgress: (p) => setState(() => _progress = p),
|
||||||
|
);
|
||||||
|
await installer.install(path);
|
||||||
|
// Stage stays 'downloading' — Android system install dialog has
|
||||||
|
// taken over. If user cancels, the banner is still here.
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stage = _Stage.error;
|
||||||
|
_error = '$e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDismiss(UpdateInfo info) {
|
||||||
|
ref.read(dismissUpdateProvider)(info.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final info = ref.watch(shouldShowUpdateBannerProvider);
|
||||||
|
if (info == null) return const SizedBox.shrink();
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: fs.iron,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.system_update, color: fs.parchment, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_stage == _Stage.error
|
||||||
|
? 'Update failed'
|
||||||
|
: 'Update Minstrel · ${info.version} available',
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_stage == _Stage.downloading) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: _progress > 0 ? _progress : null,
|
||||||
|
minHeight: 2,
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
valueColor: AlwaysStoppedAnimation(fs.accent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_stage == _Stage.error && _error != null) ...[
|
||||||
|
const SizedBox(height: 1),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_stage == _Stage.idle)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _onInstall(info),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: fs.accent,
|
||||||
|
minimumSize: const Size(56, 28),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
child: const Text('Install', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
if (_stage == _Stage.error)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _onInstall(info),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: fs.accent,
|
||||||
|
minimumSize: const Size(56, 28),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
child: const Text('Retry', style: TextStyle(fontSize: 13)),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Dismiss',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
iconSize: 16,
|
||||||
|
icon: Icon(Icons.close, color: fs.ash),
|
||||||
|
onPressed: () => _onDismiss(info),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Wire shape returned by GET /api/client/version. `version` is the
|
||||||
|
// server-bundled APK version (raw — may have a "v" prefix from the
|
||||||
|
// git tag); `apkUrl` is server-relative (e.g. "/api/client/apk").
|
||||||
|
|
||||||
|
class UpdateInfo {
|
||||||
|
const UpdateInfo({
|
||||||
|
required this.version,
|
||||||
|
required this.apkUrl,
|
||||||
|
required this.sizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String version;
|
||||||
|
final String apkUrl;
|
||||||
|
final int sizeBytes;
|
||||||
|
|
||||||
|
factory UpdateInfo.fromJson(Map<String, dynamic> j) => UpdateInfo(
|
||||||
|
version: (j['version'] as String?) ?? '',
|
||||||
|
apkUrl: (j['apk_url'] as String?) ?? '/api/client/apk',
|
||||||
|
sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
+209
-1
@@ -73,6 +73,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.6"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.15.0"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.6"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +129,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.4"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -89,6 +153,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
|
cli_util:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_util
|
||||||
|
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.2"
|
||||||
clock:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -113,6 +185,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
connectivity_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: connectivity_plus
|
||||||
|
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5"
|
||||||
|
connectivity_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: connectivity_plus_platform_interface
|
||||||
|
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
convert:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -145,6 +233,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.9"
|
version: "1.0.9"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.12"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -161,6 +265,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
drift:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift
|
||||||
|
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.31.0"
|
||||||
|
drift_dev:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: drift_dev
|
||||||
|
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.31.0"
|
||||||
|
drift_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: drift_flutter
|
||||||
|
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -320,6 +448,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
version: "8.1.0"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
hooks:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +520,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
version: "0.6.7"
|
||||||
|
json_annotation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.0"
|
||||||
just_audio:
|
just_audio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -496,6 +640,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.6"
|
version: "0.17.6"
|
||||||
|
nm:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nm
|
||||||
|
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.0"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -553,7 +705,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -640,6 +792,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
recase:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: recase
|
||||||
|
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +869,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.3"
|
||||||
source_map_stack_trace:
|
source_map_stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -765,6 +941,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
|
sqlite3:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlite3
|
||||||
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
|
sqlite3_flutter_libs:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sqlite3_flutter_libs
|
||||||
|
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.42"
|
||||||
|
sqlparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqlparser
|
||||||
|
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.43.1"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -789,6 +989,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
string_scanner:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user