Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
run: flutter pub get
|
||||
|
||||
- name: Codegen (drift, etc.)
|
||||
# build_runner generates *.g.dart files (drift schemas, etc.)
|
||||
# which are gitignored. Must run before analyze + test so the
|
||||
# generated symbols exist.
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --fatal-infos
|
||||
|
||||
@@ -68,7 +74,15 @@ jobs:
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: flutter build apk --release
|
||||
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||
# so PackageInfo.version matches what /api/client/version
|
||||
# reports — otherwise the in-app update banner would always
|
||||
# think the user is behind because pubspec.yaml's static
|
||||
# version drifts from the actual release tag.
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
- name: Upload debug APK artifact
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
@@ -81,14 +95,15 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
# Look up the release ID for this tag (release must exist already;
|
||||
# release.yml creates it as part of the server-side release flow).
|
||||
# Look up the release ID for this tag. The release object must
|
||||
# exist already — operator creates it (or release.yml will, in
|
||||
# a future enhancement) before pushing the tag.
|
||||
RELEASE_ID=$(curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
@@ -96,6 +111,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
|
||||
|
||||
@@ -50,9 +50,11 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main"
|
||||
fi
|
||||
|
||||
@@ -60,9 +62,40 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
# In-app update flow (#397): on tag pushes only, fetch the APK
|
||||
# that flutter.yml is attaching to this same release. flutter.yml
|
||||
# runs in parallel; poll up to 15 min for the asset to appear.
|
||||
# On main pushes (or if the APK never lands), client/ stays empty
|
||||
# and the endpoints return 404 — graceful degradation.
|
||||
- name: Fetch APK for bundled in-app update channel
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
|
||||
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
-o client/minstrel.apk "$APK_URL"; then
|
||||
echo "Got APK on attempt $i"
|
||||
echo "${TAG}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
exit 0
|
||||
fi
|
||||
echo "APK not ready yet (attempt $i/30), sleeping 30s..."
|
||||
sleep 30
|
||||
done
|
||||
echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel"
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
client/minstrel.apk.version
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
|
||||
+14
-2
@@ -15,7 +15,13 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
@@ -33,9 +39,15 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
||||
# A non-writable path at this location silently breaks every downstream
|
||||
# cache, so we create + chown it once at image build. Operators mount a
|
||||
# named volume on top to persist across container recreates.
|
||||
RUN mkdir -p /app/data && chown -R minstrel:minstrel /app
|
||||
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||
WORKDIR /app
|
||||
|
||||
# In-app update channel (#397). client/ in the build context holds
|
||||
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
||||
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
||||
# APK files aren't present, so non-tag images degrade gracefully.
|
||||
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||
|
||||
USER minstrel
|
||||
EXPOSE 4533
|
||||
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
||||
|
||||
@@ -48,7 +48,7 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings.
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -59,7 +59,6 @@ Most operators only need the env vars in the quickstart above. A few extras wort
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start.
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Bundled client assets
|
||||
|
||||
Holds the Android APK (`minstrel.apk` + `minstrel.apk.version`) that the
|
||||
server serves via `/api/client/version` and `/api/client/apk` for the
|
||||
in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Forgejo release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
|
||||
## Development
|
||||
|
||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
||||
update banner stays hidden ("no update channel" graceful degradation).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
file into this directory:
|
||||
|
||||
```sh
|
||||
cp /path/to/app-release.apk client/minstrel.apk
|
||||
echo "v0.2.0" > client/minstrel.apk.version
|
||||
```
|
||||
|
||||
## Why a directory and not embed.FS?
|
||||
|
||||
The APK is 30-60 MB. Embedding bloats the Go binary and slows
|
||||
`go build` for everyone, even when the APK isn't being changed.
|
||||
File-on-disk also lets operators override at runtime via volume
|
||||
mount on `/app/client/` if they want to ship their own build.
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -67,10 +66,6 @@ func run() error {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil {
|
||||
return fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
|
||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
||||
|
||||
contact := cfg.Library.ContactEmail
|
||||
|
||||
@@ -22,16 +22,6 @@ log:
|
||||
level: "info" # debug | info | warn | error
|
||||
format: "text" # text | json
|
||||
|
||||
auth:
|
||||
# One-shot admin bootstrap. Applied only when the users table is empty.
|
||||
# Username defaults to "admin" when unset — you can omit this section
|
||||
# entirely for a working bootstrap. Leave password blank to have the server
|
||||
# generate one and log it to stderr on first start.
|
||||
# Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD
|
||||
admin_bootstrap:
|
||||
username: "admin"
|
||||
password: ""
|
||||
|
||||
library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
|
||||
@@ -39,9 +39,6 @@ services:
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
||||
MINSTREL_STORAGE_DATA_DIR: /app/data
|
||||
# MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap
|
||||
# generates one and prints it to stderr on first boot. Watch the logs
|
||||
# of the minstrel service the first time you bring the stack up.
|
||||
networks:
|
||||
- minstrel
|
||||
ports:
|
||||
|
||||
@@ -43,3 +43,7 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# drift codegen output (regenerated by build_runner; CI runs build_runner)
|
||||
*.g.dart
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Used in all build flavours (debug / profile / release). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<!-- Android 13+ requires this to be declared AND prompted at runtime
|
||||
for the media notification (now-playing tile) to appear. Audio
|
||||
playback works without it; only the system-tray surface breaks. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- In-app update flow (#397). First update attempt prompts the
|
||||
user to enable "Install unknown apps" for Minstrel in
|
||||
Settings → Apps → Special access. One-time grant; persists. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<application
|
||||
android:label="minstrel"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -49,6 +60,20 @@
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- FileProvider for the in-app update flow (#397). Maps the
|
||||
cache directory (where dio.download writes the APK) to a
|
||||
content:// URI so the PackageInstaller intent can read it
|
||||
across the process boundary. file:// URIs are blocked
|
||||
since Android 7. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
+67
-2
@@ -1,5 +1,70 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
// Must extend AudioServiceActivity (not FlutterActivity) so the
|
||||
// audio_service plugin can wire its FlutterEngine through. Without this
|
||||
// the platform call from AudioService.init() throws PlatformException
|
||||
// "The Activity class declared in your AndroidManifest.xml is wrong",
|
||||
// which on first start cascades into a flutter_cache_manager sqlite
|
||||
// EXCLUSIVE-lock crash because the engine partially re-initialises.
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
|
||||
companion object {
|
||||
private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer"
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// In-app update channel (#397). Dart calls install(path) with
|
||||
// the cache-dir APK path; we hand it to Android's
|
||||
// PackageInstaller via FileProvider + ACTION_VIEW.
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"install" -> {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
result.error("missing_path", "path argument required", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
try {
|
||||
installApk(path)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("install_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
throw IllegalStateException("apk not found at $path")
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
// NEW_TASK: PackageInstaller runs in its own task.
|
||||
// GRANT_READ_URI_PERMISSION: hands the content:// URI to
|
||||
// the installer process, which lacks our app's read perms
|
||||
// by default.
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
FileProvider mappings for the in-app update flow (#397).
|
||||
- cache-path/. exposes the app's getCacheDir() so the downloaded
|
||||
APK at <cache>/minstrel-update.apk can be read by the
|
||||
PackageInstaller via the content:// URI built in MainActivity.kt.
|
||||
-->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="updates" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Permits cleartext HTTP to 127.0.0.1 ONLY. Required because just_audio +
|
||||
audio_service spin up a local HTTP proxy on loopback to inject the
|
||||
Authorization header (Android's MediaSession API can't pass headers down
|
||||
to ExoPlayer directly). Without this exemption, ExoPlayer hits Android's
|
||||
cleartext block when connecting to the local proxy and audio playback
|
||||
fails with "Cleartext HTTP traffic to 127.0.0.1 not permitted".
|
||||
|
||||
All other hosts inherit cleartextTrafficPermitted="false" — actual
|
||||
remote traffic (to your Minstrel server) must still be HTTPS.
|
||||
|
||||
Loopback traffic doesn't leave the device, so this exemption doesn't
|
||||
reduce security for real network endpoints.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_section_card.dart';
|
||||
|
||||
class AdminLandingScreen extends ConsumerWidget {
|
||||
const AdminLandingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final counts = ref.watch(adminCountsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: counts.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (c) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(adminCountsProvider.future),
|
||||
child: ListView(
|
||||
children: [
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_requests'),
|
||||
icon: Icons.inbox,
|
||||
title: 'Requests',
|
||||
subtitle: 'Approve or reject Lidarr requests',
|
||||
count: c.requests,
|
||||
onTap: () => context.push('/admin/requests'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_quarantine'),
|
||||
icon: Icons.warning_amber,
|
||||
title: 'Quarantine',
|
||||
subtitle: 'Resolve scan failures',
|
||||
count: c.quarantine,
|
||||
onTap: () => context.push('/admin/quarantine'),
|
||||
),
|
||||
AdminSectionCard(
|
||||
key: const Key('admin_card_users'),
|
||||
icon: Icons.people,
|
||||
title: 'Users',
|
||||
subtitle: 'Manage accounts and invites',
|
||||
count: c.users,
|
||||
onTap: () => context.push('/admin/users'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/admin_invites.dart';
|
||||
import '../api/endpoints/admin_quarantine.dart';
|
||||
import '../api/endpoints/admin_requests.dart';
|
||||
import '../api/endpoints/admin_users.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/admin_quarantine_item.dart';
|
||||
import '../models/admin_request.dart';
|
||||
import '../models/admin_user.dart';
|
||||
import '../models/invite.dart';
|
||||
|
||||
final adminRequestsApiProvider = FutureProvider<AdminRequestsApi>((ref) async {
|
||||
return AdminRequestsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminQuarantineApiProvider =
|
||||
FutureProvider<AdminQuarantineApi>((ref) async {
|
||||
return AdminQuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminUsersApiProvider = FutureProvider<AdminUsersApi>((ref) async {
|
||||
return AdminUsersApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final adminInvitesApiProvider = FutureProvider<AdminInvitesApi>((ref) async {
|
||||
return AdminInvitesApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
class AdminCounts {
|
||||
const AdminCounts({
|
||||
required this.requests,
|
||||
required this.quarantine,
|
||||
required this.users,
|
||||
});
|
||||
final int requests;
|
||||
final int quarantine;
|
||||
final int users;
|
||||
}
|
||||
|
||||
/// Fans out to the three list endpoints in parallel and rolls them up
|
||||
/// into `{requests, quarantine, users}` for the landing screen badges.
|
||||
final adminCountsProvider = FutureProvider<AdminCounts>((ref) async {
|
||||
final requestsApi = await ref.watch(adminRequestsApiProvider.future);
|
||||
final quarantineApi = await ref.watch(adminQuarantineApiProvider.future);
|
||||
final usersApi = await ref.watch(adminUsersApiProvider.future);
|
||||
final results = await Future.wait([
|
||||
requestsApi.list(),
|
||||
quarantineApi.list(),
|
||||
usersApi.list(),
|
||||
]);
|
||||
return AdminCounts(
|
||||
requests: (results[0] as List).length,
|
||||
quarantine: (results[1] as List).length,
|
||||
users: (results[2] as List).length,
|
||||
);
|
||||
});
|
||||
|
||||
class AdminRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async {
|
||||
final api = await ref.watch(adminRequestsApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _decide(id, (api) => api.approve(id));
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _decide(id, (api) => api.reject(id));
|
||||
}
|
||||
|
||||
Future<void> _decide(
|
||||
String id,
|
||||
Future<void> Function(AdminRequestsApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminRequestsApiProvider.future);
|
||||
final current = state.value ?? const <AdminRequest>[];
|
||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||
try {
|
||||
await action(api);
|
||||
// Refresh landing badge.
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminRequestsProvider =
|
||||
AsyncNotifierProvider<AdminRequestsController, List<AdminRequest>>(
|
||||
AdminRequestsController.new);
|
||||
|
||||
/// Read side originally defined for the Requests screen's
|
||||
/// requester-UUID → username join. Mutation methods (setAdmin,
|
||||
/// setAutoApprove, resetPassword, delete) added alongside the Users
|
||||
/// screen in Slice 3. resetPassword is admin-supplies; the server
|
||||
/// has no auto-generation mode.
|
||||
class AdminUsersController extends AsyncNotifier<List<AdminUser>> {
|
||||
@override
|
||||
Future<List<AdminUser>> build() async {
|
||||
final api = await ref.watch(adminUsersApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Optimistic flip of `is_admin` on the user row; rolls back on
|
||||
/// server error (including the last-admin guard 4xx).
|
||||
Future<void> setAdmin(String id, bool isAdmin) =>
|
||||
_patch(id, (u) => _copy(u, isAdmin: isAdmin),
|
||||
(api) => api.setAdmin(id, isAdmin));
|
||||
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) =>
|
||||
_patch(id, (u) => _copy(u, autoApproveRequests: autoApprove),
|
||||
(api) => api.setAutoApprove(id, autoApprove));
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData(current.where((u) => u.id != id).toList());
|
||||
try {
|
||||
await api.delete(id);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
await api.resetPassword(id, newPassword);
|
||||
}
|
||||
|
||||
Future<void> _patch(
|
||||
String id,
|
||||
AdminUser Function(AdminUser) mutate,
|
||||
Future<void> Function(AdminUsersApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminUsersApiProvider.future);
|
||||
final current = state.value ?? const <AdminUser>[];
|
||||
state = AsyncData([
|
||||
for (final u in current) u.id == id ? mutate(u) : u,
|
||||
]);
|
||||
try {
|
||||
await action(api);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) =>
|
||||
AdminUser(
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
isAdmin: isAdmin ?? u.isAdmin,
|
||||
autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests,
|
||||
createdAt: u.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
final adminUsersProvider =
|
||||
AsyncNotifierProvider<AdminUsersController, List<AdminUser>>(
|
||||
AdminUsersController.new);
|
||||
|
||||
class AdminQuarantineController
|
||||
extends AsyncNotifier<List<AdminQuarantineItem>> {
|
||||
@override
|
||||
Future<List<AdminQuarantineItem>> build() async {
|
||||
final api = await ref.watch(adminQuarantineApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) =>
|
||||
_act(trackId, (api) => api.resolve(trackId));
|
||||
Future<void> deleteFile(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteFile(trackId));
|
||||
Future<void> deleteViaLidarr(String trackId) =>
|
||||
_act(trackId, (api) => api.deleteViaLidarr(trackId));
|
||||
|
||||
Future<void> _act(
|
||||
String trackId,
|
||||
Future<void> Function(AdminQuarantineApi api) action,
|
||||
) async {
|
||||
final api = await ref.read(adminQuarantineApiProvider.future);
|
||||
final current = state.value ?? const <AdminQuarantineItem>[];
|
||||
state = AsyncData(current.where((q) => q.trackId != trackId).toList());
|
||||
try {
|
||||
await action(api);
|
||||
ref.invalidate(adminCountsProvider);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminQuarantineProvider = AsyncNotifierProvider<AdminQuarantineController,
|
||||
List<AdminQuarantineItem>>(AdminQuarantineController.new);
|
||||
|
||||
class AdminInvitesController extends AsyncNotifier<List<Invite>> {
|
||||
@override
|
||||
Future<List<Invite>> build() async {
|
||||
final api = await ref.watch(adminInvitesApiProvider.future);
|
||||
return api.list();
|
||||
}
|
||||
|
||||
/// Returns the freshly-minted invite so the screen can show the
|
||||
/// token in a copy-once dialog. The new invite is also prepended
|
||||
/// to the local list so it shows up without a refresh.
|
||||
Future<Invite> create({String? note}) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final invite = await api.create(note: note);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData([invite, ...current]);
|
||||
return invite;
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
final api = await ref.read(adminInvitesApiProvider.future);
|
||||
final current = state.value ?? const <Invite>[];
|
||||
state = AsyncData(current.where((i) => i.token != token).toList());
|
||||
try {
|
||||
await api.revoke(token);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final adminInvitesProvider =
|
||||
AsyncNotifierProvider<AdminInvitesController, List<Invite>>(
|
||||
AdminInvitesController.new);
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_quarantine_row.dart';
|
||||
|
||||
class AdminQuarantineScreen extends ConsumerWidget {
|
||||
const AdminQuarantineScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final items = ref.watch(adminQuarantineProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Quarantine', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: items.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No quarantined tracks.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminQuarantineProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminQuarantineProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) => AdminQuarantineRow(
|
||||
key: Key('admin_quarantine_row_${rows[i].trackId}'),
|
||||
item: rows[i],
|
||||
onResolve: () => notifier.resolve(rows[i].trackId),
|
||||
onDeleteFile: () => notifier.deleteFile(rows[i].trackId),
|
||||
onDeleteViaLidarr: () =>
|
||||
notifier.deleteViaLidarr(rows[i].trackId),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/admin_user.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_request_row.dart';
|
||||
|
||||
class AdminRequestsScreen extends ConsumerWidget {
|
||||
const AdminRequestsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final requests = ref.watch(adminRequestsProvider);
|
||||
// Best-effort lookup for requester usernames. If the users provider
|
||||
// hasn't loaded yet, valueOrNull is null and rows fall back to the
|
||||
// UUID prefix; no blocking spinner.
|
||||
final users = ref.watch(adminUsersProvider).value ?? const <AdminUser>[];
|
||||
final usersById = {for (final u in users) u.id: u};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Requests', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/requests')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No pending requests.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(adminRequestsProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async =>
|
||||
ref.refresh(adminRequestsProvider.future),
|
||||
child: ListView.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (_, i) {
|
||||
final req = rows[i];
|
||||
final user = usersById[req.userId];
|
||||
final display = user?.username ??
|
||||
(req.userId.length >= 8
|
||||
? req.userId.substring(0, 8)
|
||||
: req.userId);
|
||||
return AdminRequestRow(
|
||||
key: Key('admin_request_row_${req.id}'),
|
||||
request: req,
|
||||
requesterDisplay: display,
|
||||
onApprove: () => notifier.approve(req.id),
|
||||
onReject: () => notifier.reject(req.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'admin_providers.dart';
|
||||
import 'widgets/admin_user_edit_sheet.dart';
|
||||
import 'widgets/admin_user_row.dart';
|
||||
import 'widgets/invite_row.dart';
|
||||
|
||||
class AdminUsersScreen extends ConsumerWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.watch(adminUsersProvider);
|
||||
final invites = ref.watch(adminInvitesProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Users', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/admin/users')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(adminUsersProvider);
|
||||
ref.invalidate(adminInvitesProvider);
|
||||
},
|
||||
child: ListView(
|
||||
children: [
|
||||
const _SectionHeader(text: 'Users'),
|
||||
users.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (rows) => Column(
|
||||
children: rows
|
||||
.map((u) => AdminUserRow(
|
||||
key: Key('admin_user_row_${u.id}'),
|
||||
user: u,
|
||||
onTap: () => AdminUserEditSheet.show(context, u),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(
|
||||
text: 'Invites',
|
||||
trailing: TextButton.icon(
|
||||
key: const Key('invite_generate_button'),
|
||||
onPressed: () => _showGenerateInvite(context, ref),
|
||||
icon: Icon(Icons.add, color: fs.parchment),
|
||||
label: Text('Generate',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
),
|
||||
),
|
||||
invites.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (rows) => Column(
|
||||
children: rows
|
||||
.map((i) => InviteRow(
|
||||
key: Key('invite_row_${i.token}'),
|
||||
invite: i,
|
||||
onRevoke: () => ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.revoke(i.token),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showGenerateInvite(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
final note = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Generate invite'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Token expires in 24 hours.'),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note (optional)',
|
||||
helperText: 'e.g. "for alice"',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
child: const Text('Generate'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (note == null || !context.mounted) return;
|
||||
try {
|
||||
final invite = await ref
|
||||
.read(adminInvitesProvider.notifier)
|
||||
.create(note: note.isEmpty ? null : note);
|
||||
if (!context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Invite created'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Share this token with the new user:'),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
invite.token,
|
||||
style: const TextStyle(fontFamily: 'JetBrainsMono'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: invite.token));
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, this.trailing});
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminQuarantineRow extends StatelessWidget {
|
||||
const AdminQuarantineRow({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onResolve,
|
||||
required this.onDeleteFile,
|
||||
required this.onDeleteViaLidarr,
|
||||
});
|
||||
|
||||
final AdminQuarantineItem item;
|
||||
final VoidCallback onResolve;
|
||||
final VoidCallback onDeleteFile;
|
||||
final VoidCallback onDeleteViaLidarr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ExpansionTile(
|
||||
key: Key('admin_quarantine_tile_${item.trackId}'),
|
||||
title: Text(
|
||||
item.trackTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${item.artistName} · ${item.albumTitle}',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
Text(
|
||||
'${item.reportCount} '
|
||||
'${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}',
|
||||
style: TextStyle(color: fs.error, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
key: Key('admin_quarantine_menu_${item.trackId}'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case 'resolve':
|
||||
onResolve();
|
||||
break;
|
||||
case 'delete_file':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete file?',
|
||||
message:
|
||||
'Permanently delete the local file for "${item.trackTitle}". '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
onDeleteFile();
|
||||
}
|
||||
break;
|
||||
case 'delete_lidarr':
|
||||
if (await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete via Lidarr?',
|
||||
message:
|
||||
'Ask Lidarr to delete the file for "${item.trackTitle}".',
|
||||
)) {
|
||||
onDeleteViaLidarr();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(value: 'resolve', child: Text('Resolve')),
|
||||
PopupMenuItem(value: 'delete_file', child: Text('Delete file')),
|
||||
PopupMenuItem(
|
||||
value: 'delete_lidarr', child: Text('Delete via Lidarr')),
|
||||
],
|
||||
),
|
||||
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||||
children: [
|
||||
for (final report in item.reports)
|
||||
ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'${report.username} — ${report.reason}',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
subtitle: report.notes != null
|
||||
? Text(report.notes!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminRequestRow extends StatelessWidget {
|
||||
const AdminRequestRow({
|
||||
super.key,
|
||||
required this.request,
|
||||
required this.requesterDisplay,
|
||||
required this.onApprove,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
final AdminRequest request;
|
||||
|
||||
/// Pre-resolved username (or UUID-prefix fallback) for the requester.
|
||||
final String requesterDisplay;
|
||||
|
||||
final VoidCallback onApprove;
|
||||
final VoidCallback onReject;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final subtitle =
|
||||
'${request.kind} · ${request.artistName} · requested by $requesterDisplay';
|
||||
return ListTile(
|
||||
title: Text(
|
||||
request.displayName,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: onApprove,
|
||||
style: TextButton.styleFrom(foregroundColor: fs.moss),
|
||||
child: const Text('Approve'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reject request?'),
|
||||
content: Text('Reject "${request.displayName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) onReject();
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Reject'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminSectionCard extends StatelessWidget {
|
||||
const AdminSectionCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.count,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Card(
|
||||
color: fs.iron,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: fs.parchment),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: fs.ash)),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.bronze,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../admin_providers.dart';
|
||||
import 'typed_confirm_sheet.dart';
|
||||
|
||||
class AdminUserEditSheet extends ConsumerWidget {
|
||||
const AdminUserEditSheet({super.key, required this.user});
|
||||
|
||||
final AdminUser user;
|
||||
|
||||
static Future<void> show(BuildContext context, AdminUser user) =>
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => AdminUserEditSheet(user: user),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final users = ref.read(adminUsersProvider.notifier);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_is_admin'),
|
||||
title: Text('Admin', style: TextStyle(color: fs.parchment)),
|
||||
value: user.isAdmin,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAdmin(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
key: const Key('user_edit_auto_approve'),
|
||||
title: Text('Auto-approve requests',
|
||||
style: TextStyle(color: fs.parchment)),
|
||||
value: user.autoApproveRequests,
|
||||
onChanged: (v) async {
|
||||
try {
|
||||
await users.setAutoApprove(user.id, v);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('user_edit_reset_password'),
|
||||
onPressed: () => _resetPassword(context, users),
|
||||
child: const Text('Reset password'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
key: const Key('user_edit_delete'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
onPressed: () => _deleteUser(context, users),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _resetPassword(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
final controller = TextEditingController();
|
||||
final newPw = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reset password'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New password',
|
||||
helperText: 'Minimum 8 characters',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (controller.text.length >= 8) {
|
||||
Navigator.pop(ctx, controller.text);
|
||||
}
|
||||
},
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (newPw == null || !context.mounted) return;
|
||||
try {
|
||||
await users.resetPassword(user.id, newPw);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Password reset.')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteUser(
|
||||
BuildContext context, AdminUsersController users) async {
|
||||
if (!await TypedConfirmSheet.show(
|
||||
context,
|
||||
title: 'Delete user?',
|
||||
message:
|
||||
'Permanently delete user "${user.username}" and all their data. '
|
||||
'Cannot be undone.',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await users.delete(user.id);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AdminUserRow extends StatelessWidget {
|
||||
const AdminUserRow({super.key, required this.user, required this.onTap});
|
||||
|
||||
final AdminUser user;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
user.username,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze),
|
||||
if (user.autoApproveRequests)
|
||||
_Badge(label: 'auto-approve', color: fs.moss),
|
||||
],
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.label, required this.color});
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class InviteRow extends StatelessWidget {
|
||||
const InviteRow({
|
||||
super.key,
|
||||
required this.invite,
|
||||
required this.onRevoke,
|
||||
});
|
||||
|
||||
final Invite invite;
|
||||
final VoidCallback onRevoke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
invite.token,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: fs.ash, size: 18),
|
||||
tooltip: 'Copy',
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: invite.token)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Wrap(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text('expires ${invite.expiresAt}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.note != null)
|
||||
Text('· ${invite.note}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (invite.isRedeemed)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.ash,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'redeemed',
|
||||
style: TextStyle(color: fs.obsidian, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: fs.oxblood),
|
||||
tooltip: 'Revoke',
|
||||
onPressed: onRevoke,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Modal bottom sheet that requires the user to type [confirmWord]
|
||||
/// (default "DELETE") before the confirm button enables. Used for
|
||||
/// destructive actions like quarantine delete-file / delete-via-Lidarr
|
||||
/// and user delete.
|
||||
///
|
||||
/// Returns true if the user confirmed, false (or null → false) otherwise.
|
||||
class TypedConfirmSheet extends StatefulWidget {
|
||||
const TypedConfirmSheet({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.confirmWord = 'DELETE',
|
||||
this.confirmLabel = 'Delete',
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final String confirmWord;
|
||||
final String confirmLabel;
|
||||
|
||||
static Future<bool> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmWord = 'DELETE',
|
||||
String confirmLabel = 'Delete',
|
||||
}) async {
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => TypedConfirmSheet(
|
||||
title: title,
|
||||
message: message,
|
||||
confirmWord: confirmWord,
|
||||
confirmLabel: confirmLabel,
|
||||
),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
|
||||
}
|
||||
|
||||
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
|
||||
final _controller = TextEditingController();
|
||||
bool _enabled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
color: fs.iron,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(widget.message, style: TextStyle(color: fs.ash)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Type ${widget.confirmWord} to confirm:',
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('typed_confirm_input'),
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
onChanged: (v) =>
|
||||
setState(() => _enabled = v == widget.confirmWord),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
key: const Key('typed_confirm_button'),
|
||||
onPressed:
|
||||
_enabled ? () => Navigator.pop(context, true) : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: fs.oxblood,
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/invite.dart';
|
||||
|
||||
class AdminInvitesApi {
|
||||
AdminInvitesApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
|
||||
Future<List<Invite>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
|
||||
final raw = (r.data?['invites'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
|
||||
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
|
||||
/// Returns the bare invite (not enveloped).
|
||||
Future<Invite> create({String? note}) async {
|
||||
final r = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/admin/invites',
|
||||
data: {if (note != null && note.isNotEmpty) 'note': note},
|
||||
);
|
||||
return Invite.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<void> revoke(String token) async {
|
||||
await _dio.delete<void>('/api/admin/invites/$token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_quarantine_item.dart';
|
||||
|
||||
class AdminQuarantineApi {
|
||||
AdminQuarantineApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
|
||||
/// quarantine rows.
|
||||
Future<List<AdminQuarantineItem>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) =>
|
||||
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> resolve(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
|
||||
}
|
||||
|
||||
Future<void> deleteViaLidarr(String trackId) async {
|
||||
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
class AdminRequestsApi {
|
||||
AdminRequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/requests — flat list of pending requests.
|
||||
/// Server defaults to ?status=pending; we don't override.
|
||||
Future<List<AdminRequest>> list() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> approve(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/approve');
|
||||
}
|
||||
|
||||
Future<void> reject(String id) async {
|
||||
await _dio.post<void>('/api/admin/requests/$id/reject');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_user.dart';
|
||||
|
||||
class AdminUsersApi {
|
||||
AdminUsersApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
|
||||
Future<List<AdminUser>> list() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
|
||||
final raw = (r.data?['users'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> setAdmin(String id, bool isAdmin) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/admin',
|
||||
data: {'is_admin': isAdmin},
|
||||
);
|
||||
}
|
||||
|
||||
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
|
||||
/// — different from the response field name. Verified against
|
||||
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
|
||||
Future<void> setAutoApprove(String id, bool autoApprove) async {
|
||||
await _dio.put<void>(
|
||||
'/api/admin/users/$id/auto-approve',
|
||||
data: {'auto_approve': autoApprove},
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin supplies the new password; server returns 204. There is no
|
||||
/// server-generated-password mode.
|
||||
Future<void> resetPassword(String id, String newPassword) async {
|
||||
await _dio.post<void>(
|
||||
'/api/admin/users/$id/reset-password',
|
||||
data: {'password': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _dio.delete<void>('/api/admin/users/$id');
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,16 @@ class LibraryListsApi {
|
||||
final Dio _dio;
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||
// not /api/library/artists. Albums use /api/library/albums for
|
||||
// historical reasons; the paths aren't symmetric.
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
'/api/artists',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'sort': 'alpha',
|
||||
},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/history_event.dart';
|
||||
import '../../models/quarantine_mine.dart';
|
||||
import '../../models/system_playlists_status.dart';
|
||||
|
||||
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
||||
class MeApi {
|
||||
@@ -27,4 +28,14 @@ class MeApi {
|
||||
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// GET /api/me/system-playlists-status. Returns the caller's most
|
||||
/// recent system-playlist build state. Used by the home Playlists
|
||||
/// row to choose between real and placeholder cards.
|
||||
Future<SystemPlaylistsStatus> systemPlaylistsStatus() async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/me/system-playlists-status',
|
||||
);
|
||||
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,27 +2,55 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/playlist.dart';
|
||||
|
||||
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
|
||||
/// the UI can present them as different sections; we preserve that split
|
||||
/// to give the integrations page room to grow.
|
||||
class PlaylistsList {
|
||||
const PlaylistsList({required this.owned, required this.public});
|
||||
final List<Playlist> owned;
|
||||
final List<Playlist> public;
|
||||
|
||||
factory PlaylistsList.empty() =>
|
||||
const PlaylistsList(owned: [], public: []);
|
||||
|
||||
/// Concatenated view for callers that don't care about the split.
|
||||
List<Playlist> get all => [...owned, ...public];
|
||||
}
|
||||
|
||||
class PlaylistsApi {
|
||||
PlaylistsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/playlists?kind=user|system|all. The server's default kind
|
||||
/// is "user" — passing "all" returns user playlists + system mixes.
|
||||
/// "system" returns just for-you / discover. The mobile UI defaults
|
||||
/// to "all" so users see their generated mixes alongside their own.
|
||||
Future<List<Playlist>> list({String kind = 'all'}) async {
|
||||
final r = await _dio.get<List<dynamic>>(
|
||||
/// GET /api/playlists?kind=user|system|all. Server returns
|
||||
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
|
||||
/// playlists, filtered by the kind param (default "user"). Public
|
||||
/// is other users' shared playlists; not filtered by kind.
|
||||
Future<PlaylistsList> list({String kind = 'user'}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/playlists',
|
||||
queryParameters: {'kind': kind},
|
||||
);
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
final body = r.data ?? const <String, dynamic>{};
|
||||
List<Playlist> parse(String key) {
|
||||
final raw = (body[key] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
return PlaylistsList(owned: parse('owned'), public: parse('public'));
|
||||
}
|
||||
|
||||
Future<PlaylistDetail> get(String id) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
/// POST /api/playlists/{id}/tracks. Owner only; server returns the
|
||||
/// playlist detail with the new rows.
|
||||
Future<void> appendTracks(String playlistId, List<String> trackIds) async {
|
||||
await _dio.post<void>(
|
||||
'/api/playlists/$playlistId/tracks',
|
||||
data: {'track_ids': trackIds},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// /api/quarantine — flag a track (with reason + optional notes) and
|
||||
/// unflag it. Both endpoints are user-scoped: callers can only flag
|
||||
/// their own quarantine entries; admins use a separate /admin/quarantine
|
||||
/// surface.
|
||||
class QuarantineApi {
|
||||
QuarantineApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// POST /api/quarantine. Server returns 201 with the row.
|
||||
/// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other.
|
||||
Future<void> flag(String trackId, String reason, {String notes = ''}) async {
|
||||
await _dio.post<void>('/api/quarantine', data: {
|
||||
'track_id': trackId,
|
||||
'reason': reason,
|
||||
'notes': notes,
|
||||
});
|
||||
}
|
||||
|
||||
/// DELETE /api/quarantine/{track_id}. Server returns 204.
|
||||
Future<void> unflag(String trackId) async {
|
||||
await _dio.delete<void>('/api/quarantine/$trackId');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
|
||||
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
|
||||
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
|
||||
/// scored by the server's recommendation engine. The shape matches
|
||||
/// what `playerActions.playTracks` expects, so a radio start is just
|
||||
/// one fetch + one playTracks call.
|
||||
class RadioApi {
|
||||
RadioApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/radio',
|
||||
queryParameters: {
|
||||
'seed_track': trackId,
|
||||
if (limit != null) 'limit': limit,
|
||||
},
|
||||
);
|
||||
final raw = (r.data?['tracks'] as List?) ?? const [];
|
||||
return raw
|
||||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
/// User-side requests API — `/api/requests`. Server scopes results to
|
||||
/// the caller; admins see only their own here, not all users'. The
|
||||
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
|
||||
///
|
||||
/// Wire shape is identical to the admin endpoint, so the same
|
||||
/// AdminRequest model is reused.
|
||||
class RequestsApi {
|
||||
RequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/requests — caller's own requests, all statuses.
|
||||
Future<List<AdminRequest>> listMine() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// DELETE /api/requests/{id} — cancel a pending request. Server
|
||||
/// returns the cancelled row body (not 204) so the caller can patch
|
||||
/// local state without a refetch.
|
||||
Future<AdminRequest> cancel(String id) async {
|
||||
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
|
||||
return AdminRequest.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'cache/prefetcher.dart';
|
||||
import 'cache/sync_controller.dart';
|
||||
import 'shared/routing.dart';
|
||||
import 'theme/theme_data.dart';
|
||||
import 'theme/theme_mode_provider.dart';
|
||||
|
||||
class MinstrelApp extends ConsumerWidget {
|
||||
class MinstrelApp extends ConsumerStatefulWidget {
|
||||
const MinstrelApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
|
||||
}
|
||||
|
||||
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Activate offline-mode infrastructure once the first frame ships.
|
||||
// SyncController.sync() is connectivity-aware (no-ops on no auth /
|
||||
// no server URL), so calling it unconditionally is safe.
|
||||
// Reading prefetcherProvider runs its constructor, which wires the
|
||||
// queue + settings listeners.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// ignore: unawaited_futures
|
||||
ref.read(syncControllerProvider.notifier).sync();
|
||||
ref.read(prefetcherProvider);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final router = ref.watch(routerProvider);
|
||||
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||
return MaterialApp.router(
|
||||
title: 'Minstrel',
|
||||
theme: buildThemeData(),
|
||||
theme: buildLightTheme(),
|
||||
darkTheme: buildDarkTheme(),
|
||||
themeMode: mode.materialMode,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// 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.
|
||||
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
||||
id: id,
|
||||
userId: userId,
|
||||
name: name,
|
||||
description: description,
|
||||
isPublic: isPublic,
|
||||
systemVariant: systemVariant,
|
||||
trackCount: trackCount,
|
||||
coverUrl: '', // server-derived; cache doesn't persist
|
||||
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),
|
||||
);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<int> usageBytes() async {
|
||||
final result = await _db.customSelect(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
|
||||
readsFrom: {_db.audioCacheIndex},
|
||||
).getSingle();
|
||||
return result.read<int>('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');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+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 '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/lidarr.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
|
||||
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
||||
@@ -87,6 +88,7 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/discover')],
|
||||
),
|
||||
body: Column(children: [
|
||||
Padding(
|
||||
@@ -160,8 +162,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||
final rows = snap.data ?? const [];
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No matches.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
child: Text('Nothing to add for that search yet.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
textAlign: TextAlign.center),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
|
||||
@@ -2,30 +2,105 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class AlbumDetailScreen extends ConsumerWidget {
|
||||
const AlbumDetailScreen({required this.id, super.key});
|
||||
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional album reference passed by the caller (typically the
|
||||
/// tile they tapped) so the header can render immediately while
|
||||
/// the full provider loads tracks. Hydration only — provider data
|
||||
/// still wins once it arrives.
|
||||
final AlbumRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final live = ref.watch(albumProvider(id));
|
||||
|
||||
// Pick the best header info available: live data > seed > nothing.
|
||||
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||
? live.value!.album.title
|
||||
: seed?.title ?? '';
|
||||
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||
? live.value!.album.artistName
|
||||
: seed?.artistName ?? '';
|
||||
|
||||
Widget header() => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
if (headerArtist.isNotEmpty)
|
||||
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: ref.watch(albumProvider(id)).when(
|
||||
body: live.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => seed != null
|
||||
? ListView(children: [
|
||||
header(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
])
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
data: (r) => ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(width: 96, height: 96, color: fs.slate),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -38,6 +113,21 @@ class AlbumDetailScreen extends ConsumerWidget {
|
||||
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
key: const Key('download_album_button'),
|
||||
icon: Icon(Icons.download, color: fs.ash),
|
||||
tooltip: 'Download album',
|
||||
onPressed: () {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
for (final t in r.tracks) {
|
||||
// ignore: unawaited_futures
|
||||
mgr.pin(t.id, source: CacheSource.autoPlaylist);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||
|
||||
@@ -5,34 +5,75 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
|
||||
class ArtistDetailScreen extends ConsumerWidget {
|
||||
const ArtistDetailScreen({required this.id, super.key});
|
||||
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional artist reference from the caller. Lets the screen render
|
||||
/// the name + avatar immediately while albums load.
|
||||
final ArtistRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(artistAlbumsProvider(id));
|
||||
|
||||
// Resolve which artist info to render in the header. Live wins
|
||||
// when present and populated; seed fills the gap during the
|
||||
// first frame after navigation.
|
||||
final liveArtist = artist.value;
|
||||
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: artist.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (a) => ListView(children: [
|
||||
// While loading: render header from seed if available so the
|
||||
// page isn't blank.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _artistBody(context, ref, seed!, albums, fs),
|
||||
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ArtistRef a,
|
||||
AsyncValue<List<AlbumRef>> albums,
|
||||
FabledSwordTheme fs,
|
||||
) =>
|
||||
ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 96, height: 96,
|
||||
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
// Server derives artist cover from a representative
|
||||
// album. Drift cache doesn't persist that pointer, so
|
||||
// mirror the trick client-side: reuse the first album
|
||||
// returned by artistAlbumsProvider. Falls back to
|
||||
// slate while albums are loading or empty.
|
||||
child: _ArtistAvatar(
|
||||
serverCoverUrl: a.coverUrl,
|
||||
albums: albums,
|
||||
fs: fs,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -68,23 +109,76 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
albums.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||
data: (list) => GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
|
||||
},
|
||||
),
|
||||
data: (list) => LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW =
|
||||
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||
cols;
|
||||
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||
// ≈ 36) + small fudge. Artist line is suppressed in this
|
||||
// grid (showArtist: false) since the page header already
|
||||
// names the artist.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 4;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
showArtist: false,
|
||||
onTap: () =>
|
||||
context.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||
/// non-empty; otherwise we mirror the server's "use the first album's
|
||||
/// cover" rule client-side via the loaded album list.
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({
|
||||
required this.serverCoverUrl,
|
||||
required this.albums,
|
||||
required this.fs,
|
||||
});
|
||||
final String serverCoverUrl;
|
||||
final AsyncValue<List<AlbumRef>> albums;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (serverCoverUrl.isNotEmpty) {
|
||||
return ServerImage(
|
||||
url: serverCoverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
||||
if (firstId == null) {
|
||||
return Container(color: fs.slate);
|
||||
}
|
||||
return ServerImage(
|
||||
url: '/api/albums/$firstId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,20 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../playlists/playlists_provider.dart';
|
||||
import '../playlists/widgets/playlist_card.dart';
|
||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||
import '../shared/widgets/connection_error_banner.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
import 'widgets/artist_card.dart';
|
||||
import 'widgets/compact_track_card.dart';
|
||||
import 'widgets/horizontal_scroll_row.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -22,99 +27,385 @@ class HomeScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final home = ref.watch(homeProvider);
|
||||
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
||||
final status = ref.watch(systemPlaylistsStatusProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||
actions: [
|
||||
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'),
|
||||
),
|
||||
],
|
||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ref.watch(homeProvider).when(
|
||||
error: (e, _) {
|
||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||
if (code == 'connection_refused') {
|
||||
return ConnectionErrorBanner(
|
||||
onRetry: () => ref.refresh(homeProvider),
|
||||
);
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(children: [
|
||||
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
|
||||
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
|
||||
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
|
||||
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
|
||||
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
),
|
||||
child: home.when(
|
||||
error: (e, _) {
|
||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||
if (code == 'connection_refused') {
|
||||
return ConnectionErrorBanner(
|
||||
onRetry: () => ref.refresh(homeProvider),
|
||||
);
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => _HomeSkeleton(fs: fs),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(
|
||||
// ClampingScrollPhysics: no bounce overscroll past the
|
||||
// content. Combined with the bottom padding below, this
|
||||
// makes "scroll to end" land the last item just above the
|
||||
// player bar — no empty void.
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albums: h.rediscoverAlbums,
|
||||
artists: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
||||
// Bottom padding ≈ player bar height (top row 80 + seek
|
||||
// 30 + padding 16 ≈ ~140) so the last section's bottom
|
||||
// edge lands right above the player when scrolled.
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _albumsRow(BuildContext ctx, String title, List<AlbumRef> albums) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
children: [
|
||||
for (final a in albums)
|
||||
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
children: [
|
||||
for (final a in artists)
|
||||
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
|
||||
HorizontalScrollRow(
|
||||
title: title,
|
||||
height: 64,
|
||||
children: [
|
||||
for (final t in tracks)
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: TrackRow(
|
||||
track: t,
|
||||
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaylistsSection extends StatelessWidget {
|
||||
const _PlaylistsSection({required this.playlists, required this.status});
|
||||
final List<Playlist> playlists;
|
||||
final SystemPlaylistsStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _buildPlaylistsRow(playlists, status);
|
||||
return HorizontalScrollRow(
|
||||
title: 'Playlists',
|
||||
height: 220,
|
||||
children: items.map((item) {
|
||||
if (item is _RealPlaylist) {
|
||||
return PlaylistCard(playlist: item.playlist);
|
||||
}
|
||||
final ph = item as _PlaceholderPlaylist;
|
||||
return PlaylistPlaceholderCard(label: ph.label, variant: ph.variant);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _PlaylistRowItem {}
|
||||
class _RealPlaylist extends _PlaylistRowItem {
|
||||
_RealPlaylist(this.playlist);
|
||||
final Playlist playlist;
|
||||
}
|
||||
class _PlaceholderPlaylist extends _PlaylistRowItem {
|
||||
_PlaceholderPlaylist(this.label, this.variant);
|
||||
final String label;
|
||||
final String variant;
|
||||
}
|
||||
|
||||
List<_PlaylistRowItem> _buildPlaylistsRow(
|
||||
List<Playlist> ownedAll,
|
||||
SystemPlaylistsStatus status,
|
||||
) {
|
||||
final out = <_PlaylistRowItem>[];
|
||||
|
||||
Playlist? findFirst(bool Function(Playlist) test) {
|
||||
for (final p in ownedAll) {
|
||||
if (test(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Slot 1: For-You.
|
||||
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
||||
out.add(forYou != null
|
||||
? _RealPlaylist(forYou)
|
||||
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
||||
|
||||
// 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:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/client.dart';
|
||||
import '../api/endpoints/library.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/adapters.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/home_data.dart';
|
||||
@@ -37,24 +46,273 @@ final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
||||
});
|
||||
|
||||
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
||||
/// when empty + online, fetches via REST + populates drift, which
|
||||
/// re-emits via watch().
|
||||
final artistProvider =
|
||||
FutureProvider.family<ArtistRef, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
|
||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedArtist, ArtistRef>(
|
||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||
.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtist(id);
|
||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const ArtistRef(id: '', name: '')
|
||||
: rows.first.toRef(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// SWR: yield cache instantly on hit, refresh in the background so
|
||||
// the row catches up to server state without making the user wait.
|
||||
alwaysRefresh: true,
|
||||
tag: 'artist($id)',
|
||||
);
|
||||
});
|
||||
|
||||
final artistAlbumsProvider =
|
||||
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
|
||||
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
// Join cached_albums + cached_artists to fill artist_name on each row.
|
||||
final query = db.select(db.cachedAlbums).join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||
])..where(db.cachedAlbums.artistId.equals(artistId));
|
||||
|
||||
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtistAlbums(artistId);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
|
||||
});
|
||||
},
|
||||
toResult: (rows) => rows.map((r) {
|
||||
final album = r.readTable(db.cachedAlbums);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
return album.toRef(artistName: artist?.name ?? '');
|
||||
}).toList(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'artistAlbums($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
final artistTracksProvider =
|
||||
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
|
||||
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final query = db.select(db.cachedTracks).join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||
])..where(db.cachedTracks.artistId.equals(artistId));
|
||||
|
||||
return cacheFirst<drift.TypedResult, List<TrackRef>>(
|
||||
driftStream: query.watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getArtistTracks(artistId);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
|
||||
});
|
||||
},
|
||||
toResult: (rows) => rows.map((r) {
|
||||
final track = r.readTable(db.cachedTracks);
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
final album = r.readTableOrNull(db.cachedAlbums);
|
||||
return track.toRef(
|
||||
artistName: artist?.name ?? '',
|
||||
albumTitle: album?.title ?? '',
|
||||
);
|
||||
}).toList(),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'artistTracks($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
final albumProvider =
|
||||
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
|
||||
(ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
|
||||
},
|
||||
);
|
||||
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
||||
/// for reactive updates over both rows.
|
||||
final albumProvider = StreamProvider.family<
|
||||
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
|
||||
final db = ref.watch(appDbProvider);
|
||||
|
||||
final albumQuery = (db.select(db.cachedAlbums)
|
||||
..where((t) => t.id.equals(albumId)))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||
]);
|
||||
|
||||
final tracksQuery = (db.select(db.cachedTracks)
|
||||
..where((t) => t.albumId.equals(albumId))
|
||||
..orderBy([
|
||||
(t) => drift.OrderingTerm.asc(t.discNumber),
|
||||
(t) => drift.OrderingTerm.asc(t.trackNumber),
|
||||
]))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
]);
|
||||
|
||||
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
||||
// server genuinely returns zero tracks (or if the fetch fails).
|
||||
var fetchAttempted = false;
|
||||
// SWR revalidation guard — fire one background refresh per
|
||||
// subscription on the first complete cache hit, so the displayed
|
||||
// data catches up to server state without making the user wait.
|
||||
var revalidated = false;
|
||||
|
||||
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();
|
||||
|
||||
// SWR: first complete cache hit triggers one background refresh
|
||||
// so we don't show stale data indefinitely. Drift watch re-emits
|
||||
// on success and the next iteration yields the fresh data.
|
||||
if (!revalidated && trackRows.isNotEmpty) {
|
||||
revalidated = true;
|
||||
if (await isOnline()) {
|
||||
unawaited(fetchAndPopulate());
|
||||
}
|
||||
}
|
||||
|
||||
yield (album: album, tracks: tracks);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../models/page.dart' as wire;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
import 'widgets/artist_card.dart';
|
||||
@@ -90,6 +91,7 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/library')],
|
||||
bottom: TabBar(
|
||||
controller: _ctrl,
|
||||
isScrollable: true,
|
||||
@@ -130,7 +132,7 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
? Center(child: Text('No artists.', style: TextStyle(color: fs.ash)))
|
||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
child: GridView.builder(
|
||||
@@ -142,10 +144,14 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: page.items[i],
|
||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
||||
),
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = page.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => ctx.push('/artists/${artist.id}',
|
||||
extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -162,23 +168,44 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
? Center(child: Text('No albums.', style: TextStyle(color: fs.ash)))
|
||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: page.items[i],
|
||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
||||
),
|
||||
),
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW = (constraints.maxWidth -
|
||||
sidePad * 2 -
|
||||
gap * (cols - 1)) /
|
||||
cols;
|
||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||
// + artist line (~16) + small fudge.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 4;
|
||||
return 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 +266,7 @@ class _LikedTab extends ConsumerWidget {
|
||||
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||
}
|
||||
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
||||
return Center(child: Text('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(
|
||||
onRefresh: () async {
|
||||
@@ -258,10 +285,14 @@ class _LikedTab extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: ar.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: ar.items[i],
|
||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
||||
),
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = ar.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () =>
|
||||
ctx.push('/artists/${artist.id}', extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -273,10 +304,14 @@ class _LikedTab extends ConsumerWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: al.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: al.items[i],
|
||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
||||
),
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = al.items[i];
|
||||
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 '../../models/album.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
const AlbumCard({required this.album, required this.onTap, super.key});
|
||||
const AlbumCard({
|
||||
required this.album,
|
||||
required this.onTap,
|
||||
this.width = 140,
|
||||
this.titleMaxLines = 1,
|
||||
this.showArtist = true,
|
||||
super.key,
|
||||
});
|
||||
final AlbumRef album;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Outer width of the card. Cover is square at width - 16 (8dp
|
||||
/// horizontal padding either side). Default suits horizontal lists;
|
||||
/// grids should pass the cell width so the card scales down.
|
||||
final double width;
|
||||
|
||||
/// Lets callers (e.g. the artist detail grid) allow the title to
|
||||
/// wrap to a second line so it isn't truncated to a single
|
||||
/// ellipsized character at narrower widths.
|
||||
final int titleMaxLines;
|
||||
|
||||
/// Suppress the artist name. Useful on surfaces where the artist is
|
||||
/// already implied by the page header (e.g. artist detail).
|
||||
final bool showArtist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: album.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: Image.network(album.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
final coverSize = width - 16;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: fs.slate,
|
||||
child: album.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
fit: BoxFit.cover)
|
||||
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.title,
|
||||
maxLines: titleMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (showArtist && album.artistName.isNotEmpty)
|
||||
Text(
|
||||
album.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
album.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../models/artist.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
@@ -12,31 +13,35 @@ class ArtistCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: Image.network(artist.coverUrl, fit: BoxFit.cover),
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
fit: BoxFit.cover)
|
||||
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../cache/audio_cache_manager.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Small download glyph shown next to a track row when the track is
|
||||
/// cached locally. FutureBuilder one-shot — track rows are short-lived
|
||||
/// and cache state rarely changes mid-render.
|
||||
class CachedIndicator extends ConsumerWidget {
|
||||
const CachedIndicator({required this.trackId, super.key});
|
||||
final String trackId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mgr = ref.read(audioCacheManagerProvider);
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return FutureBuilder<bool>(
|
||||
future: mgr.isCached(trackId),
|
||||
builder: (ctx, snap) {
|
||||
if (snap.data != true) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.download_done, size: 14, color: fs.accent),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Small horizontal track cell used by the home Most-played section.
|
||||
/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall).
|
||||
/// Tap plays from this track within [sectionTracks] starting at [index].
|
||||
class CompactTrackCard extends ConsumerWidget {
|
||||
const CompactTrackCard({
|
||||
super.key,
|
||||
required this.track,
|
||||
required this.sectionTracks,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final TrackRef track;
|
||||
|
||||
/// All tracks in the home section so play-from-here can queue them
|
||||
/// in order.
|
||||
final List<TrackRef> sectionTracks;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// Cover URL derives from the track's album. Mirrors web
|
||||
// CompactTrackCard which calls coverUrl(track.album_id).
|
||||
final coverUrl = track.albumId.isNotEmpty
|
||||
? '/api/albums/${track.albumId}/cover'
|
||||
: '';
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: InkWell(
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(sectionTracks, initialIndex: index),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: ServerImage(
|
||||
url: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TrackActionsButton(track: track),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,18 @@ class HorizontalScrollRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
if (title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 18,
|
||||
color: fs.parchment,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: ListView(
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/track.dart';
|
||||
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import 'cached_indicator.dart';
|
||||
|
||||
class TrackRow extends StatelessWidget {
|
||||
const TrackRow({
|
||||
required this.track,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
this.actions = true,
|
||||
super.key,
|
||||
});
|
||||
final TrackRef track;
|
||||
final VoidCallback onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Render the 3-dot TrackActionsButton at the end of the row. Default
|
||||
/// true; pass false in surfaces that don't want the menu (e.g. when
|
||||
/// showing a static read-only list).
|
||||
final bool actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
@@ -22,38 +30,48 @@ class TrackRow extends StatelessWidget {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Row(children: [
|
||||
if (track.trackNumber != null)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
width: 22,
|
||||
child: Text(
|
||||
track.trackNumber.toString(),
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
// Skip the artist line entirely when empty so the row
|
||||
// height collapses to a single line of title — keeps
|
||||
// dense album views from looking padded.
|
||||
if (track.artistName.isNotEmpty)
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CachedIndicator(trackId: track.id),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (trailing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: trailing!,
|
||||
),
|
||||
if (actions) TrackActionsButton(track: track),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ class LikeButton extends ConsumerWidget {
|
||||
color: liked ? fs.accent : fs.ash,
|
||||
size: size,
|
||||
),
|
||||
onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id),
|
||||
onPressed: () => ref.read(likesControllerProvider).toggle(kind, id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../library/library_providers.dart';
|
||||
|
||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||
@@ -22,67 +28,154 @@ class LikedIds {
|
||||
LikeKind.album => albums.contains(id),
|
||||
LikeKind.track => tracks.contains(id),
|
||||
};
|
||||
|
||||
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
|
||||
}
|
||||
|
||||
class LikedIdsController extends AsyncNotifier<LikedIds> {
|
||||
@override
|
||||
Future<LikedIds> build() async {
|
||||
final api = await ref.watch(likesApiProvider.future);
|
||||
final r = await api.ids();
|
||||
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
|
||||
}
|
||||
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
|
||||
/// SyncController). Reactive — sync writes propagate via watch().
|
||||
/// Empty + online triggers REST cold-cache fallback that re-populates.
|
||||
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedLike, LikedIds>(
|
||||
driftStream: db.select(db.cachedLikes).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(likesApiProvider.future);
|
||||
final fresh = await api.ids();
|
||||
// Need a userId for the composite primary key. The auth controller
|
||||
// exposes the current user.
|
||||
final user = ref.read(authControllerProvider).value;
|
||||
if (user == null) return; // not logged in; skip
|
||||
await db.batch((b) {
|
||||
for (final id in fresh.tracks) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'track',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
for (final id in fresh.albums) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'album',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
for (final id in fresh.artists) {
|
||||
b.insert(
|
||||
db.cachedLikes,
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: 'artist',
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
toResult: (rows) => LikedIds(
|
||||
tracks: rows
|
||||
.where((r) => r.entityType == 'track')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
albums: rows
|
||||
.where((r) => r.entityType == 'album')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
artists: rows
|
||||
.where((r) => r.entityType == 'artist')
|
||||
.map((r) => r.entityId)
|
||||
.toSet(),
|
||||
),
|
||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||
);
|
||||
});
|
||||
|
||||
/// Mutation controller. Writes optimistically to drift first (so the
|
||||
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
|
||||
/// REST. Rolls back drift on REST failure.
|
||||
class LikesController {
|
||||
LikesController(this._ref);
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> toggle(LikeKind kind, String id) async {
|
||||
final api = await ref.read(likesApiProvider.future);
|
||||
final current = state.value ??
|
||||
const LikedIds(artists: {}, albums: {}, tracks: {});
|
||||
final user = _ref.read(authControllerProvider).value;
|
||||
if (user == null) return;
|
||||
final db = _ref.read(appDbProvider);
|
||||
final entityType = _entityType(kind);
|
||||
|
||||
final isLiked = current.has(kind, id);
|
||||
final optimistic = _flip(current, kind, id);
|
||||
state = AsyncData(optimistic);
|
||||
final existing = await (db.select(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.getSingleOrNull();
|
||||
final wasLiked = existing != null;
|
||||
|
||||
// Optimistic mutation — drift watch() re-emits immediately.
|
||||
if (wasLiked) {
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.go();
|
||||
} else {
|
||||
await db.into(db.cachedLikes).insert(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: entityType,
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isLiked) {
|
||||
final api = await _ref.read(likesApiProvider.future);
|
||||
if (wasLiked) {
|
||||
await api.unlike(kind, id);
|
||||
} else {
|
||||
await api.like(kind, id);
|
||||
}
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
// Rollback drift
|
||||
if (wasLiked) {
|
||||
await db.into(db.cachedLikes).insert(
|
||||
CachedLikesCompanion.insert(
|
||||
userId: user.id,
|
||||
entityType: entityType,
|
||||
entityId: id,
|
||||
),
|
||||
mode: drift.InsertMode.insertOrIgnore,
|
||||
);
|
||||
} else {
|
||||
await (db.delete(db.cachedLikes)
|
||||
..where((t) =>
|
||||
t.userId.equals(user.id) &
|
||||
t.entityType.equals(entityType) &
|
||||
t.entityId.equals(id)))
|
||||
.go();
|
||||
}
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
|
||||
Set<String> swap(Set<String> s) {
|
||||
final next = {...s};
|
||||
if (s.contains(id)) {
|
||||
next.remove(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
return switch (kind) {
|
||||
LikeKind.artist => LikedIds(
|
||||
artists: swap(before.artists),
|
||||
albums: before.albums,
|
||||
tracks: before.tracks,
|
||||
),
|
||||
LikeKind.album => LikedIds(
|
||||
artists: before.artists,
|
||||
albums: swap(before.albums),
|
||||
tracks: before.tracks,
|
||||
),
|
||||
LikeKind.track => LikedIds(
|
||||
artists: before.artists,
|
||||
albums: before.albums,
|
||||
tracks: swap(before.tracks),
|
||||
),
|
||||
};
|
||||
}
|
||||
String _entityType(LikeKind kind) => switch (kind) {
|
||||
LikeKind.artist => 'artist',
|
||||
LikeKind.album => 'album',
|
||||
LikeKind.track => 'track',
|
||||
};
|
||||
}
|
||||
|
||||
final likedIdsProvider =
|
||||
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
|
||||
final likesControllerProvider =
|
||||
Provider<LikesController>((ref) => LikesController(ref));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/// One user's report under an aggregated quarantine row.
|
||||
class AdminQuarantineReport {
|
||||
const AdminQuarantineReport({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.reason,
|
||||
this.notes,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String username;
|
||||
final String reason;
|
||||
final String? notes;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminQuarantineReport.fromJson(Map<String, dynamic> j) =>
|
||||
AdminQuarantineReport(
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
reason: j['reason'] as String? ?? '',
|
||||
notes: j['notes'] as String?,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go.
|
||||
/// One row aggregates all reports against a single track, with
|
||||
/// per-reason counts and the underlying per-user reports nested.
|
||||
class AdminQuarantineItem {
|
||||
const AdminQuarantineItem({
|
||||
required this.trackId,
|
||||
required this.trackTitle,
|
||||
required this.artistName,
|
||||
required this.albumTitle,
|
||||
required this.albumId,
|
||||
this.lidarrAlbumMbid,
|
||||
required this.reportCount,
|
||||
required this.latestAt,
|
||||
required this.reasonCounts,
|
||||
required this.reports,
|
||||
});
|
||||
|
||||
final String trackId;
|
||||
final String trackTitle;
|
||||
final String artistName;
|
||||
final String albumTitle;
|
||||
final String albumId;
|
||||
final String? lidarrAlbumMbid;
|
||||
final int reportCount;
|
||||
final String latestAt;
|
||||
|
||||
/// Map of reason → number of reports citing that reason.
|
||||
final Map<String, int> reasonCounts;
|
||||
|
||||
final List<AdminQuarantineReport> reports;
|
||||
|
||||
/// One-line summary of the most-cited reason. Returns just the top
|
||||
/// reason when only one reason exists, or `top (+N more)` when there
|
||||
/// are additional distinct reasons.
|
||||
String get topReasonSummary {
|
||||
if (reasonCounts.isEmpty) return '';
|
||||
final entries = reasonCounts.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
final top = entries.first.key;
|
||||
return entries.length > 1
|
||||
? '$top (+${entries.length - 1} more)'
|
||||
: top;
|
||||
}
|
||||
|
||||
factory AdminQuarantineItem.fromJson(Map<String, dynamic> j) {
|
||||
final reasonCountsRaw =
|
||||
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? const {};
|
||||
final reportsRaw = (j['reports'] as List?) ?? const [];
|
||||
return AdminQuarantineItem(
|
||||
trackId: j['track_id'] as String? ?? '',
|
||||
trackTitle: j['track_title'] as String? ?? '',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String? ?? '',
|
||||
albumId: j['album_id'] as String? ?? '',
|
||||
lidarrAlbumMbid: j['lidarr_album_mbid'] as String?,
|
||||
reportCount: (j['report_count'] as int?) ?? 0,
|
||||
latestAt: j['latest_at'] as String? ?? '',
|
||||
reasonCounts:
|
||||
reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)),
|
||||
reports: reportsRaw
|
||||
.map((e) =>
|
||||
AdminQuarantineReport.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/// Mirrors `requestView` from internal/api/requests.go. Server returns
|
||||
/// these as a flat list (no envelope) from GET /api/admin/requests.
|
||||
///
|
||||
/// Note the requester is exposed only as a UUID (`user_id`) — the
|
||||
/// response does not include the username. The Flutter Requests screen
|
||||
/// joins client-side against the AdminUser list for display.
|
||||
class AdminRequest {
|
||||
const AdminRequest({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.status,
|
||||
required this.kind,
|
||||
required this.artistName,
|
||||
this.albumTitle,
|
||||
this.trackTitle,
|
||||
required this.requestedAt,
|
||||
this.decidedAt,
|
||||
this.notes,
|
||||
required this.importedAlbumCount,
|
||||
required this.importedTrackCount,
|
||||
this.matchedTrackId,
|
||||
this.matchedAlbumId,
|
||||
this.matchedArtistId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String userId;
|
||||
|
||||
/// One of: pending, approved, rejected, completed, failed.
|
||||
final String status;
|
||||
|
||||
/// One of: artist, album, track.
|
||||
final String kind;
|
||||
|
||||
final String artistName;
|
||||
final String? albumTitle;
|
||||
final String? trackTitle;
|
||||
final String requestedAt;
|
||||
final String? decidedAt;
|
||||
final String? notes;
|
||||
final int importedAlbumCount;
|
||||
final int importedTrackCount;
|
||||
|
||||
/// Set when the ingest matched into the local library. The user-side
|
||||
/// "Listen" CTA on a completed request links to whichever id is set
|
||||
/// (most-specific first: track → album → artist).
|
||||
final String? matchedTrackId;
|
||||
final String? matchedAlbumId;
|
||||
final String? matchedArtistId;
|
||||
|
||||
/// Display label depending on the kind of request — what the user
|
||||
/// asked for. For an album request it's the album title; for an
|
||||
/// artist request it's the artist name; etc.
|
||||
String get displayName => switch (kind) {
|
||||
'album' => albumTitle ?? artistName,
|
||||
'track' => trackTitle ?? artistName,
|
||||
_ => artistName,
|
||||
};
|
||||
|
||||
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
|
||||
id: j['id'] as String? ?? '',
|
||||
userId: j['user_id'] as String? ?? '',
|
||||
status: j['status'] as String? ?? 'pending',
|
||||
kind: j['kind'] as String? ?? 'artist',
|
||||
artistName: j['artist_name'] as String? ?? '',
|
||||
albumTitle: j['album_title'] as String?,
|
||||
trackTitle: j['track_title'] as String?,
|
||||
requestedAt: j['requested_at'] as String? ?? '',
|
||||
decidedAt: j['decided_at'] as String?,
|
||||
notes: j['notes'] as String?,
|
||||
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
|
||||
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
|
||||
matchedTrackId: j['matched_track_id'] as String?,
|
||||
matchedAlbumId: j['matched_album_id'] as String?,
|
||||
matchedArtistId: j['matched_artist_id'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct
|
||||
/// from MyProfile because admin endpoints expose `auto_approve_requests`
|
||||
/// and the canonical `created_at` that the user-scoped /me response
|
||||
/// intentionally omits.
|
||||
///
|
||||
/// The list endpoint wraps these in `{"users": [...]}`; the parsing of
|
||||
/// the envelope is done in `AdminUsersApi`, not here.
|
||||
class AdminUser {
|
||||
const AdminUser({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
required this.isAdmin,
|
||||
required this.autoApproveRequests,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final bool isAdmin;
|
||||
final bool autoApproveRequests;
|
||||
final String createdAt;
|
||||
|
||||
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
|
||||
id: j['id'] as String? ?? '',
|
||||
username: j['username'] as String? ?? '',
|
||||
displayName: j['display_name'] as String?,
|
||||
isAdmin: j['is_admin'] as bool? ?? false,
|
||||
autoApproveRequests: j['auto_approve_requests'] as bool? ?? false,
|
||||
createdAt: j['created_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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,58 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../models/track.dart';
|
||||
import 'album_cover_cache.dart';
|
||||
|
||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
MinstrelAudioHandler() {
|
||||
_player.playbackEventStream.listen(_broadcastState);
|
||||
_player.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));
|
||||
}
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
String _baseUrl = '';
|
||||
String? _token;
|
||||
AlbumCoverCache? _coverCache;
|
||||
AudioCacheManager? _audioCacheManager;
|
||||
|
||||
void configure({required String baseUrl, required String? token}) {
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
double get volume => _player.volume;
|
||||
|
||||
/// Position stream for UI subscribers. just_audio emits roughly every
|
||||
/// 200ms while playing, which is what makes the seek bar advance
|
||||
/// smoothly. PlaybackState.updatePosition only changes on event
|
||||
/// transitions (play/pause/buffer), so it's too coarse for live
|
||||
/// scrubbing UI.
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
Duration get position => _player.position;
|
||||
|
||||
void configure({
|
||||
required String baseUrl,
|
||||
required String? token,
|
||||
AlbumCoverCache? coverCache,
|
||||
AudioCacheManager? audioCacheManager,
|
||||
}) {
|
||||
_baseUrl = baseUrl;
|
||||
_token = token;
|
||||
if (coverCache != null) _coverCache = coverCache;
|
||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||
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 {
|
||||
@@ -24,29 +62,160 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||
}
|
||||
|
||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||
final sources = tracks.map((t) {
|
||||
final url = t.streamUrl.isNotEmpty
|
||||
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
|
||||
? t.streamUrl
|
||||
: '$_baseUrl${t.streamUrl}')
|
||||
: '$_baseUrl/api/tracks/${t.id}/stream';
|
||||
return AudioSource.uri(Uri.parse(url), headers: headers);
|
||||
}).toList();
|
||||
debugPrint('audio_handler.setQueueFromTracks: '
|
||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
||||
|
||||
await _player.setAudioSources(
|
||||
sources,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
|
||||
// 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(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
String _resolveStreamUrl(TrackRef t) {
|
||||
if (t.streamUrl.isEmpty) {
|
||||
return '$_baseUrl/api/tracks/${t.id}/stream';
|
||||
}
|
||||
final parsed = Uri.tryParse(t.streamUrl);
|
||||
if (parsed != null && parsed.hasScheme) {
|
||||
return t.streamUrl;
|
||||
}
|
||||
return '$_baseUrl${t.streamUrl}';
|
||||
}
|
||||
|
||||
/// Builds an AudioSource for a track. Cache-aware:
|
||||
/// 1. If the track is fully cached on disk, returns a file:// source.
|
||||
/// 2. Else returns a LockCachingAudioSource that streams + caches as
|
||||
/// it plays (subsequent plays will hit the cache).
|
||||
///
|
||||
/// Without an audio cache manager configured (e.g. in older code paths
|
||||
/// that pre-date #357), falls back to a plain network AudioSource.uri.
|
||||
Future<AudioSource> _buildAudioSource(TrackRef t) async {
|
||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||
final mgr = _audioCacheManager;
|
||||
|
||||
// 1. Cache hit: play from disk, no headers needed.
|
||||
if (mgr != null) {
|
||||
final path = await mgr.pathFor(t.id);
|
||||
if (path != null) {
|
||||
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) {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final cacheFile = File('${cacheDir.path}/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,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
Future<void> play() => _player.play();
|
||||
@@ -63,7 +232,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
@override
|
||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||
|
||||
void _broadcastState(PlaybackEvent event) {
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await _player
|
||||
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
||||
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
final loop = switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => LoopMode.off,
|
||||
AudioServiceRepeatMode.one => LoopMode.one,
|
||||
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
||||
};
|
||||
await _player.setLoopMode(loop);
|
||||
}
|
||||
|
||||
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
||||
/// page audio to system volume — this is the in-app slider for parity
|
||||
/// with desktop/web; on mobile the system volume is the real control.
|
||||
Future<void> setVolume(double v) async {
|
||||
await _player.setVolume(v.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
// _broadcastState accepts a nullable event because the shuffle/repeat
|
||||
// listeners don't have one — we just want to re-emit PlaybackState
|
||||
// with up-to-date shuffleMode/repeatMode fields.
|
||||
void _broadcastState(PlaybackEvent? event) {
|
||||
final playing = _player.playing;
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [
|
||||
@@ -71,7 +267,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {MediaAction.seek},
|
||||
// androidCompactActionIndices tells the system which controls
|
||||
// appear in the collapsed/lock-screen view. Without this, some
|
||||
// Android versions render the player without working buttons.
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
// systemActions enumerates which actions the system can invoke
|
||||
// on us — without play/pause/skip in here, taps on lock-screen
|
||||
// controls don't route back to the handler on Android 13+.
|
||||
systemActions: const {
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.skipToNext,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.seek,
|
||||
},
|
||||
processingState: switch (_player.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
@@ -83,7 +292,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
queueIndex: event?.currentIndex ?? _player.currentIndex,
|
||||
shuffleMode: _player.shuffleModeEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: switch (_player.loopMode) {
|
||||
LoopMode.off => AudioServiceRepeatMode.none,
|
||||
LoopMode.one => AudioServiceRepeatMode.one,
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class NowPlayingScreen extends ConsumerWidget {
|
||||
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||
/// transition (see routing.dart). Dismisses three ways:
|
||||
///
|
||||
/// - System back / leading button → Navigator.pop
|
||||
/// - Vertical drag down past threshold → Navigator.pop (matches the
|
||||
/// slide-down animation users expect from a "pull down to close"
|
||||
/// sheet)
|
||||
/// - Tap of the mini player ascends here; reverse motion descends back.
|
||||
class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
||||
}
|
||||
|
||||
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// Cumulative drag distance used to decide if a vertical drag should
|
||||
// dismiss. Reset on each drag start.
|
||||
double _dragOffset = 0;
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
_dragOffset += d.delta.dy;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
// Pop when either the user has dragged > 80px down OR flicked
|
||||
// downward at speed (>500 px/s). Either should feel responsive
|
||||
// without dismissing on accidental drags.
|
||||
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
|
||||
if (_dragOffset > 80 || flicked) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
@@ -18,66 +62,356 @@ class NowPlayingScreen extends ConsumerWidget {
|
||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||
}
|
||||
|
||||
final pos = playback?.updatePosition ?? Duration.zero;
|
||||
// Use positionProvider (just_audio's positionStream, ~200ms) for
|
||||
// the seek bar so it scrubs live; PlaybackState.updatePosition
|
||||
// only fires on state transitions and would leave the bar frozen
|
||||
// between them.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||
// and the seek slider are still tappable since gesture arena
|
||||
// gives priority to their child gesture detectors.
|
||||
body: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(fs: fs),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
_AlbumArt(media: media, albumId: albumId, fs: fs),
|
||||
const SizedBox(height: 28),
|
||||
_TitleRow(media: media, fs: fs),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((media.album ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
media.album!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 24),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SecondaryControls(
|
||||
fs: fs,
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
),
|
||||
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) {
|
||||
// Stack: title centered absolutely in the row; like + kebab pinned
|
||||
// to the right edge. Padding on the title equals the actions' width
|
||||
// so it stays optically centered without colliding with them.
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 64),
|
||||
child: Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SecondaryControls extends StatelessWidget {
|
||||
const _SecondaryControls({
|
||||
required this.fs,
|
||||
required this.actions,
|
||||
required this.shuffleOn,
|
||||
required this.repeatMode,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final PlayerActions actions;
|
||||
final bool shuffleOn;
|
||||
final AudioServiceRepeatMode repeatMode;
|
||||
|
||||
@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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,295 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||
/// view only — the heavyweight shuffle/repeat/queue/volume controls
|
||||
/// live in NowPlayingScreen, accessible by tap or swipe-up.
|
||||
///
|
||||
/// ┌─────────────────────────────────────┬──────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
||||
/// │ Artist │ │
|
||||
/// ├─────────────────────────────────────────────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └─────────────────────────────────────────────────┘
|
||||
///
|
||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||
/// the drag-down dismissal on the full screen.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mediaItem = ref.watch(mediaItemProvider).value;
|
||||
final media = ref.watch(mediaItemProvider).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(
|
||||
color: fs.iron,
|
||||
child: InkWell(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/now-playing'),
|
||||
// Swipe up anywhere on the bar to expand into the full player.
|
||||
// We only act on a clear upward flick (>200 px/s) so a slow
|
||||
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
||||
onVerticalDragEnd: (d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -200) context.push('/now-playing');
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(children: [
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _TrackInfo(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SeekRow(position: pos, duration: dur),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfo extends StatelessWidget {
|
||||
const _TrackInfo({required this.media});
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
return Row(
|
||||
// Default centering vertically aligns the like/kebab buttons
|
||||
// against the album art (48dp) — visually they span the title +
|
||||
// artist block instead of sitting on the title baseline.
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (media.artUri != null)
|
||||
Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
||||
// would render as a tiny inset. Cover stretches/crops to
|
||||
// fill the box uniformly.
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
)
|
||||
else
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Like + kebab as siblings to the title/artist column rather
|
||||
// than nested inside the title row. Width stays 32dp each so
|
||||
// horizontal footprint matches what we had; height stretches
|
||||
// to the row's full 48dp so the icons sit at vertical center
|
||||
// against the title+artist block.
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: LikeButton(
|
||||
kind: LikeKind.track,
|
||||
id: media.id,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayControls extends StatelessWidget {
|
||||
const _PlayControls({required this.playback, required this.ref});
|
||||
final PlaybackState? playback;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 32,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends ConsumerWidget {
|
||||
const _SeekRow({required this.position, required this.duration});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final maxMs =
|
||||
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(position),
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(duration),
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
||||
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
||||
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
||||
/// audio_handler stashed at queue-build time.
|
||||
TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
// artist_id is stashed in extras by audio_handler — without it
|
||||
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
);
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/radio.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/track.dart';
|
||||
import 'album_cover_cache.dart';
|
||||
import 'audio_handler.dart';
|
||||
|
||||
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
||||
throw UnimplementedError('overridden in main()');
|
||||
});
|
||||
|
||||
final albumCoverCacheProvider = Provider<AlbumCoverCache>((ref) {
|
||||
return AlbumCoverCache(
|
||||
dioFactory: () => ref.read(dioProvider.future),
|
||||
);
|
||||
});
|
||||
|
||||
final playbackStateProvider = StreamProvider<PlaybackState>(
|
||||
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
||||
);
|
||||
@@ -21,6 +31,23 @@ final queueProvider = StreamProvider<List<MediaItem>>(
|
||||
(ref) => ref.watch(audioHandlerProvider).queue,
|
||||
);
|
||||
|
||||
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
|
||||
/// modify via PlayerActions.setVolume(). Surfaced separately from
|
||||
/// PlaybackState because audio_service's PlaybackState doesn't carry
|
||||
/// volume — it's a player-side concern.
|
||||
final volumeProvider = StreamProvider<double>(
|
||||
(ref) => ref.watch(audioHandlerProvider).volumeStream,
|
||||
);
|
||||
|
||||
/// Live playback position. just_audio emits at ~200ms cadence so seek
|
||||
/// bars driven by this provider scrub smoothly. Don't use
|
||||
/// PlaybackState.updatePosition for that — it only changes on state
|
||||
/// transitions (play/pause/buffer/seek) and the bar would appear
|
||||
/// frozen between events.
|
||||
final positionProvider = StreamProvider<Duration>(
|
||||
(ref) => ref.watch(audioHandlerProvider).positionStream,
|
||||
);
|
||||
|
||||
class PlayerActions {
|
||||
PlayerActions(this._ref);
|
||||
final Ref _ref;
|
||||
@@ -28,10 +55,63 @@ class PlayerActions {
|
||||
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
final url = await _ref.read(serverUrlProvider.future);
|
||||
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||
final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token);
|
||||
final cache = _ref.read(albumCoverCacheProvider);
|
||||
final audioCache = _ref.read(audioCacheManagerProvider);
|
||||
final h = _ref.read(audioHandlerProvider)
|
||||
..configure(
|
||||
baseUrl: url ?? '',
|
||||
token: token,
|
||||
coverCache: cache,
|
||||
audioCacheManager: audioCache,
|
||||
);
|
||||
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||
await h.play();
|
||||
}
|
||||
|
||||
Future<void> playNext(TrackRef track) async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.playNext(track);
|
||||
}
|
||||
|
||||
Future<void> enqueue(TrackRef track) async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.enqueue(track);
|
||||
}
|
||||
|
||||
/// Toggles shuffle on/off. Pre-existing audio_service convention.
|
||||
Future<void> toggleShuffle() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = state.shuffleMode == AudioServiceShuffleMode.none
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none;
|
||||
await h.setShuffleMode(next);
|
||||
}
|
||||
|
||||
/// Cycles repeat mode: none → all → one → none.
|
||||
Future<void> cycleRepeat() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = switch (state.repeatMode) {
|
||||
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
|
||||
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
|
||||
_ => AudioServiceRepeatMode.none,
|
||||
};
|
||||
await h.setRepeatMode(next);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double v) async {
|
||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||
}
|
||||
|
||||
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
||||
/// returned track list (seed at index 0 + recommended picks).
|
||||
Future<void> startRadio(String trackId) async {
|
||||
final dio = await _ref.read(dioProvider.future);
|
||||
final tracks = await RadioApi(dio).seedTrack(trackId);
|
||||
if (tracks.isEmpty) return;
|
||||
await playTracks(tracks);
|
||||
}
|
||||
}
|
||||
|
||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||
|
||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
@@ -50,6 +53,7 @@ class _Body extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final tracks = detail.tracks;
|
||||
final playable = tracks.where((t) => t.isAvailable).toList();
|
||||
|
||||
@@ -57,9 +61,22 @@ class _Body extends ConsumerWidget {
|
||||
onRefresh: () async =>
|
||||
ref.refresh(playlistDetailProvider(detail.playlist.id).future),
|
||||
child: ListView.builder(
|
||||
itemCount: tracks.length + 1, // header + rows
|
||||
// Header + (track rows | empty hint).
|
||||
itemCount: tracks.isEmpty ? 2 : tracks.length + 1,
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == 0) return _Header(detail: detail, playable: playable);
|
||||
if (tracks.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No tracks yet. Add some via the "Add to playlist…" entry on any track row.',
|
||||
style: TextStyle(color: fs.ash),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final t = tracks[i - 1];
|
||||
return _PlaylistTrackRow(
|
||||
row: t,
|
||||
@@ -123,7 +140,26 @@ class _Header extends ConsumerWidget {
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
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(
|
||||
onPressed: () {
|
||||
final refs = playable.map(_toTrackRef).toList(growable: false);
|
||||
@@ -136,6 +172,7 @@ class _Header extends ConsumerWidget {
|
||||
foregroundColor: fs.parchment,
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
]),
|
||||
);
|
||||
@@ -184,6 +221,8 @@ class _PlaylistTrackRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
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 '../models/playlist.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
@@ -22,13 +24,15 @@ class PlaylistsListScreen extends ConsumerWidget {
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
title: Text('Playlists', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/playlists')],
|
||||
),
|
||||
body: list.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (items) {
|
||||
data: (lists) {
|
||||
final items = lists.all;
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
@@ -79,7 +83,7 @@ class _PlaylistTile extends StatelessWidget {
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
: Image.network(playlist.coverUrl, fit: BoxFit.cover),
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -1,19 +1,316 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../api/endpoints/playlists.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/adapters.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/cache_first.dart';
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
|
||||
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
||||
return PlaylistsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Drift-first per #357 plan C. Returns cached playlists filtered to
|
||||
/// match the `kind` family arg:
|
||||
/// - 'user' → only user-created (systemVariant null), owned by current user
|
||||
/// - 'system' → only system-generated (systemVariant non-null), owned
|
||||
/// - 'all' → everything: own user playlists + own system + others' public
|
||||
///
|
||||
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
|
||||
/// behavior leaked system playlists into add-to-playlist sheet because we
|
||||
/// couldn't filter locally.
|
||||
final playlistsListProvider =
|
||||
FutureProvider.family<List<Playlist>, String>((ref, kind) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).list(kind: kind);
|
||||
StreamProvider.family<PlaylistsList, String>((ref, kind) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
return cacheFirst<CachedPlaylist, PlaylistsList>(
|
||||
driftStream: db.select(db.cachedPlaylists).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.list(kind: kind);
|
||||
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}');
|
||||
await db.batch((b) {
|
||||
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 =
|
||||
FutureProvider.family<PlaylistDetail, String>((ref, id) async {
|
||||
return (await ref.watch(playlistsApiProvider.future)).get(id);
|
||||
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
|
||||
final db = ref.watch(appDbProvider);
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
|
||||
final playlistQuery = db.select(db.cachedPlaylists)
|
||||
..where((t) => t.id.equals(id));
|
||||
|
||||
final tracksQuery = (db.select(db.cachedPlaylistTracks)
|
||||
..where((t) => t.playlistId.equals(id))
|
||||
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
|
||||
.join([
|
||||
drift.leftOuterJoin(db.cachedTracks,
|
||||
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
|
||||
drift.leftOuterJoin(db.cachedArtists,
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
drift.leftOuterJoin(db.cachedAlbums,
|
||||
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||
]);
|
||||
|
||||
PlaylistDetail emptyDetail() => PlaylistDetail(
|
||||
playlist: Playlist(
|
||||
id: id,
|
||||
userId: user?.id ?? '',
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
systemVariant: null,
|
||||
trackCount: 0,
|
||||
coverUrl: '',
|
||||
ownerUsername: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
),
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> fetchAndPopulate() async {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
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 artist + album rows referenced by these tracks so
|
||||
// the JOINs that build artistName/albumTitle in the row view
|
||||
// have something to bind to. Without this, system-playlist tracks
|
||||
// surface with empty artist/album columns.
|
||||
final artists = <String, ArtistRefRow>{};
|
||||
final albums = <String, AlbumRefRow>{};
|
||||
for (final t in fresh.tracks) {
|
||||
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 (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) {
|
||||
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;
|
||||
var revalidated = 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);
|
||||
|
||||
// SWR: first complete cache hit kicks one background refresh.
|
||||
if (!revalidated && trackRows.isNotEmpty) {
|
||||
revalidated = true;
|
||||
if (await isOnline()) {
|
||||
unawaited(fetchAndPopulate());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
final systemPlaylistsStatusProvider =
|
||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return MeApi(dio).systemPlaylistsStatus();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
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 '../models/search_response.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
@@ -73,6 +74,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
_focus.requestFocus();
|
||||
},
|
||||
),
|
||||
const MainAppBarActions(currentRoute: '/search'),
|
||||
],
|
||||
),
|
||||
body: results.when(
|
||||
@@ -86,7 +88,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
data: (r) => r == null
|
||||
? const _Hint(message: 'Type to search your library.')
|
||||
: r.isEmpty
|
||||
? const _Hint(message: 'No matches.')
|
||||
? const _Hint(message: 'No matches for that query.')
|
||||
: _Results(results: r),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,10 @@ import '../api/endpoints/settings.dart';
|
||||
import '../api/errors.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/my_profile.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'storage_section.dart';
|
||||
import '../theme/theme_mode_provider.dart';
|
||||
|
||||
final _settingsApiProvider = FutureProvider<SettingsApi>((ref) async {
|
||||
return SettingsApi(await ref.watch(dioProvider.future));
|
||||
@@ -37,15 +40,23 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Settings', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/settings')],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: const [
|
||||
_ProfileSection(),
|
||||
_Divider(),
|
||||
_RequestsSection(),
|
||||
_Divider(),
|
||||
_AppearanceSection(),
|
||||
_Divider(),
|
||||
StorageSection(),
|
||||
_Divider(),
|
||||
_PasswordSection(),
|
||||
_Divider(),
|
||||
_ListenBrainzSection(),
|
||||
_AdminSection(),
|
||||
SizedBox(height: 96),
|
||||
],
|
||||
),
|
||||
@@ -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 {
|
||||
const _ProfileSection();
|
||||
@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 '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
@@ -15,8 +17,15 @@ import '../player/player_bar.dart';
|
||||
import '../player/queue_screen.dart';
|
||||
import '../playlists/playlist_detail_screen.dart';
|
||||
import '../playlists/playlists_list_screen.dart';
|
||||
import '../requests/requests_screen.dart';
|
||||
import '../search/search_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../update/client_update_provider.dart';
|
||||
import '../update/update_banner.dart';
|
||||
import '../admin/admin_landing_screen.dart';
|
||||
import '../admin/admin_requests_screen.dart';
|
||||
import '../admin/admin_quarantine_screen.dart';
|
||||
import '../admin/admin_users_screen.dart';
|
||||
import 'widgets/version_gate.dart';
|
||||
|
||||
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
||||
@@ -39,25 +48,63 @@ GoRouter buildRouter(Ref ref) {
|
||||
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
||||
return '/home';
|
||||
}
|
||||
if (loc.startsWith('/admin') && !user!.isAdmin) {
|
||||
return '/home';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
||||
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
// /now-playing lives outside the ShellRoute on purpose. The full
|
||||
// player IS the player UI when active, and we don't want the mini
|
||||
// bar from the shell underneath fighting for the bottom strip.
|
||||
// Pushing this route unmounts the shell entirely; the slide-up
|
||||
// transition still feels right because the shell stays painted
|
||||
// for the duration of the animation.
|
||||
GoRoute(
|
||||
path: '/now-playing',
|
||||
pageBuilder: (_, __) => CustomTransitionPage(
|
||||
child: const NowPlayingScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||
transitionsBuilder: (_, anim, __, child) {
|
||||
final eased = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(eased),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||
routes: [
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/artists/:id',
|
||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
||||
// `extra` carries an optional ArtistRef so the detail
|
||||
// header renders immediately while tracks/albums load.
|
||||
builder: (_, s) => ArtistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => AlbumDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
@@ -68,20 +115,41 @@ GoRouter buildRouter(Ref ref) {
|
||||
path: '/playlists/: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});
|
||||
final Widget child;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The banner sits above the routed child and uses SafeArea to clear
|
||||
// the system status bar. MediaQuery.padding is screen-relative
|
||||
// though, so the child's Scaffold/AppBar would still apply its own
|
||||
// top status-bar inset on top of the banner — doubling the gap.
|
||||
// When the banner is showing, strip that inset from the child so
|
||||
// its AppBar lands directly under the banner.
|
||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: child),
|
||||
const UpdateBanner(),
|
||||
Expanded(
|
||||
child: hasBanner
|
||||
? MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
const PlayerBar(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Shared AppBar `actions` for top-level screens. Renders Home / Library /
|
||||
/// Search as primary icons (suppressing the icon for [currentRoute]) plus
|
||||
/// a kebab overflow with Playlists / Discover / Settings and (for admins)
|
||||
/// Admin.
|
||||
class MainAppBarActions extends ConsumerWidget {
|
||||
const MainAppBarActions({super.key, required this.currentRoute});
|
||||
|
||||
final String currentRoute;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final user = ref.watch(authControllerProvider).value;
|
||||
final isAdmin = user?.isAdmin ?? false;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (currentRoute != '/home')
|
||||
IconButton(
|
||||
key: const Key('app_bar_home'),
|
||||
icon: Icon(Icons.home, color: fs.parchment),
|
||||
tooltip: 'Home',
|
||||
onPressed: () => context.go('/home'),
|
||||
),
|
||||
if (currentRoute != '/library')
|
||||
IconButton(
|
||||
key: const Key('app_bar_library'),
|
||||
icon: Icon(Icons.library_music, color: fs.parchment),
|
||||
tooltip: 'Library',
|
||||
onPressed: () => context.push('/library'),
|
||||
),
|
||||
if (currentRoute != '/search')
|
||||
IconButton(
|
||||
key: const Key('app_bar_search'),
|
||||
icon: Icon(Icons.search, color: fs.parchment),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('app_bar_overflow'),
|
||||
icon: Icon(Icons.more_vert, color: fs.parchment),
|
||||
tooltip: 'More',
|
||||
onSelected: (route) => context.push(route),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: '/playlists', child: Text('Playlists')),
|
||||
const PopupMenuItem(value: '/discover', child: Text('Discover')),
|
||||
const PopupMenuItem(value: '/settings', child: Text('Settings')),
|
||||
if (isAdmin)
|
||||
const PopupMenuItem(value: '/admin', child: Text('Admin')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
|
||||
/// Image.network wrapper that resolves server-relative URLs (e.g.
|
||||
/// `/api/albums/<id>/cover`) against the configured server base URL
|
||||
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
||||
/// unchanged. Empty/null base or empty URL render the [fallback]
|
||||
/// (defaults to a transparent SizedBox so callers can supply their own
|
||||
/// surrounding placeholder).
|
||||
///
|
||||
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
||||
/// stream URLs, so cover art and audio resolve URLs the same way.
|
||||
class ServerImage extends ConsumerWidget {
|
||||
const ServerImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.fit,
|
||||
this.fallback,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final BoxFit? fit;
|
||||
final Widget? fallback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final empty = fallback ?? const SizedBox.shrink();
|
||||
if (url.isEmpty) return empty;
|
||||
final base = ref.watch(serverUrlProvider).value;
|
||||
final resolved = _resolve(base, url);
|
||||
if (resolved == null) return empty;
|
||||
// Cover endpoints are gated by RequireUser server-side. Image.network
|
||||
// doesn't carry cookies/Bearer tokens automatically the way the
|
||||
// browser's <img> tag does, so we forward the session token as a
|
||||
// header. Without this, the server returns 401 and the image fails.
|
||||
//
|
||||
// sessionTokenProvider is a FutureProvider — on first read after a
|
||||
// hot restart its .value is null until the secure-storage read
|
||||
// resolves. If we fired Image.network with headers:null at that
|
||||
// moment, the network image cache would lock in the 401 response
|
||||
// and never retry. Instead, hold the fallback until the token is
|
||||
// available, then mount the Image with the auth header attached.
|
||||
final tokenAsync = ref.watch(sessionTokenProvider);
|
||||
return tokenAsync.when(
|
||||
loading: () => empty,
|
||||
error: (_, __) => empty,
|
||||
data: (token) {
|
||||
final headers = (token != null && token.isNotEmpty)
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null;
|
||||
return Image.network(
|
||||
resolved,
|
||||
fit: fit,
|
||||
headers: headers,
|
||||
// Keep failures local — a single 401/timeout shouldn't dump
|
||||
// a stack trace or replace the parent Container's background.
|
||||
errorBuilder: (_, __, ___) => empty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolve(String? baseUrl, String url) {
|
||||
final parsed = Uri.tryParse(url);
|
||||
if (parsed != null && parsed.hasScheme) return url;
|
||||
if (baseUrl == null || baseUrl.isEmpty) return null;
|
||||
final base = baseUrl.endsWith('/')
|
||||
? baseUrl.substring(0, baseUrl.length - 1)
|
||||
: baseUrl;
|
||||
final path = url.startsWith('/') ? url : '/$url';
|
||||
return '$base$path';
|
||||
}
|
||||
}
|
||||
@@ -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 'tokens.dart';
|
||||
|
||||
ThemeData buildThemeData() {
|
||||
final fs = FabledSwordTheme.fromTokens();
|
||||
ThemeData buildDarkTheme() {
|
||||
final fs = FabledSwordTheme.dark();
|
||||
final colorScheme = ColorScheme.dark(
|
||||
surface: fs.iron,
|
||||
onSurface: fs.parchment,
|
||||
@@ -14,16 +14,45 @@ ThemeData buildThemeData() {
|
||||
secondary: fs.moss,
|
||||
error: fs.error,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: fs.obsidian,
|
||||
textTheme: GoogleFonts.interTextTheme().apply(
|
||||
bodyColor: fs.parchment,
|
||||
displayColor: fs.parchment,
|
||||
),
|
||||
fontFamily: FabledSwordTokens.fontBody,
|
||||
fontFamily: FabledSwordFlatTokens.fontBody,
|
||||
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 TextStyle display, body, mono;
|
||||
|
||||
static FabledSwordTheme fromTokens() => const FabledSwordTheme(
|
||||
accent: FabledSwordTokens.accent,
|
||||
obsidian: FabledSwordTokens.obsidian,
|
||||
iron: FabledSwordTokens.iron,
|
||||
slate: FabledSwordTokens.slate,
|
||||
pewter: FabledSwordTokens.pewter,
|
||||
parchment: FabledSwordTokens.parchment,
|
||||
vellum: FabledSwordTokens.vellum,
|
||||
ash: FabledSwordTokens.ash,
|
||||
moss: FabledSwordTokens.moss,
|
||||
bronze: FabledSwordTokens.bronze,
|
||||
oxblood: FabledSwordTokens.oxblood,
|
||||
warning: FabledSwordTokens.warning,
|
||||
error: FabledSwordTokens.error,
|
||||
info: FabledSwordTokens.info,
|
||||
display: TextStyle(fontFamily: FabledSwordTokens.fontDisplay),
|
||||
body: TextStyle(fontFamily: FabledSwordTokens.fontBody),
|
||||
mono: TextStyle(fontFamily: FabledSwordTokens.fontMono),
|
||||
factory FabledSwordTheme.dark() => const FabledSwordTheme(
|
||||
accent: FabledSwordFlatTokens.accent,
|
||||
obsidian: FabledSwordDarkTokens.obsidian,
|
||||
iron: FabledSwordDarkTokens.iron,
|
||||
slate: FabledSwordDarkTokens.slate,
|
||||
pewter: FabledSwordDarkTokens.pewter,
|
||||
parchment: FabledSwordDarkTokens.parchment,
|
||||
vellum: FabledSwordDarkTokens.vellum,
|
||||
ash: FabledSwordDarkTokens.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),
|
||||
);
|
||||
|
||||
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
|
||||
FabledSwordTheme copyWith({
|
||||
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.
|
||||
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 {
|
||||
static const Color obsidian = Color(0xFF14171A);
|
||||
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"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +129,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -113,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -145,6 +233,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -161,6 +265,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -320,6 +448,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +520,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -496,6 +640,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -553,7 +705,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -640,6 +792,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -701,6 +869,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -765,6 +941,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -789,6 +989,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: minstrel
|
||||
description: Minstrel mobile client
|
||||
publish_to: 'none'
|
||||
version: 0.1.0+1
|
||||
version: 2026.5.11+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
@@ -23,12 +23,19 @@ dependencies:
|
||||
package_info_plus: ^8.3.1
|
||||
pub_semver: ^2.1.4
|
||||
cupertino_icons: ^1.0.8
|
||||
path_provider: ^2.1.5
|
||||
drift: ^2.18.0
|
||||
drift_flutter: ^0.2.0
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
connectivity_plus: ^6.0.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
mocktail: ^1.0.4
|
||||
drift_dev: ^2.18.0
|
||||
build_runner: ^2.4.13
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/admin/admin_landing_screen.dart';
|
||||
import 'package:minstrel/admin/admin_providers.dart';
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'admin', isAdmin: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders three section cards with counts from provider',
|
||||
(t) async {
|
||||
await t.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
adminCountsProvider.overrideWith(
|
||||
(_) async => const AdminCounts(
|
||||
requests: 3,
|
||||
quarantine: 1,
|
||||
users: 5,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const AdminLandingScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('admin_card_requests')), findsOneWidget);
|
||||
expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget);
|
||||
expect(find.byKey(const Key('admin_card_users')), findsOneWidget);
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user