Compare commits
370 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccbd3b62a0 | |||
| 5c1a5f5e8b | |||
| bb1ab3a2e3 | |||
| 154626ae94 | |||
| 1646b02ca2 | |||
| 19de0c2874 | |||
| 1dc298c111 | |||
| e605335339 | |||
| 22a4649bfc | |||
| 01a1ac148e | |||
| bc34d96329 | |||
| 8e7660c05e | |||
| eaddb2478a | |||
| 53be834e89 | |||
| c681191b42 | |||
| 94871437dd | |||
| 359a072937 | |||
| 835592f073 | |||
| 84f16c25f6 | |||
| 25ee54fca0 | |||
| c659165218 | |||
| 0d80a113fa | |||
| 5d37616d52 | |||
| 77a4a55522 | |||
| 41dd2892e3 | |||
| c80dc0b306 | |||
| 57ce3d2d0a | |||
| 905f05c120 | |||
| e68e1b10a6 | |||
| 0d410630a2 | |||
| b76ea66165 | |||
| 412b9e37eb | |||
| a86b7a5e07 | |||
| 5e91efe695 | |||
| 47572b2e95 | |||
| 53a02322fb | |||
| 75163ea483 | |||
| d212621eaa | |||
| 5a048cbea2 | |||
| 760b4a7c6c | |||
| a7bea43a13 | |||
| 62db8edcdb | |||
| 9ffe33a6f2 | |||
| 3b142e5332 | |||
| 005965d6de | |||
| 4fca0e66cb | |||
| 4d2aebe3ed | |||
| ca1bc5af62 | |||
| e2432caa65 | |||
| 2e7b81fdfe | |||
| ec0cc37bc9 | |||
| 1e2c486356 | |||
| e772938a3b | |||
| 0c2b86e736 | |||
| ce36760819 | |||
| 2d9775244c | |||
| 035b000bb0 | |||
| a59967be20 | |||
| a5e2abb8c4 | |||
| a4f293b7cf | |||
| 3f61079c02 | |||
| a0aea00667 | |||
| c7ee0871a5 | |||
| b9accf6934 | |||
| 222742e368 | |||
| d67c0de596 | |||
| e3957b8eed | |||
| 1379595e82 | |||
| a571282031 | |||
| 47aa178850 | |||
| 8652d7124e | |||
| 3054e8702b | |||
| 179519689b | |||
| e43281d1d0 | |||
| d2a0b7d780 | |||
| 69569a5c2b | |||
| 45c72993f3 | |||
| 7a0437087a | |||
| d12afdad6e | |||
| 33b11a3b3d | |||
| 29fee5aa37 | |||
| 02336967b4 | |||
| 452e29bc59 | |||
| f6ee837be6 | |||
| d27dd69bfc | |||
| b991fde3fe | |||
| 335940cf23 | |||
| 60085b1368 | |||
| fb95a462fb | |||
| 67bacac84b | |||
| e59ccba961 | |||
| e38189470b | |||
| 5511f87b4b | |||
| e5ab471ce1 | |||
| 28b0107925 | |||
| bfad4dddb6 | |||
| d1e276204e | |||
| 2df35e6227 | |||
| 573aa4226d | |||
| 30fc7603d4 | |||
| 86d67f6fc6 | |||
| 8cb9a8b797 | |||
| 7339815ea9 | |||
| 2a18e91c39 | |||
| 507c532f6d | |||
| bf0ef5e0c3 | |||
| 6efb3159d5 | |||
| 8f1bc60757 | |||
| c29d25d1cb | |||
| 2ebe6229b7 | |||
| 6a08d94255 | |||
| 3e7b2582a2 | |||
| baa601765e | |||
| 3db90020d4 | |||
| 7367595e71 | |||
| 158a5d7506 | |||
| f2fa441405 | |||
| a324454efe | |||
| 03c13d21c6 | |||
| 0119eacf14 | |||
| 0504cae27c | |||
| 64db364834 | |||
| 99462185b4 | |||
| 395a6efb26 | |||
| 1a2de0e738 | |||
| 32c8d4f28f | |||
| f732c49645 | |||
| ae5de91006 | |||
| 369ed800b9 | |||
| 7cfaafd360 | |||
| 22bd06a578 | |||
| efa52484d4 | |||
| 3e52ff7fa3 | |||
| 89ded7b46c | |||
| bb9e979876 | |||
| e282766268 | |||
| 046ee8d576 | |||
| b5c5dbbe76 | |||
| 872b0de304 | |||
| 89d8b4b5a0 | |||
| 6b7e4f1dee | |||
| c2168afbf0 | |||
| 7fac264c73 | |||
| 46c8edfa82 | |||
| 230da7bdcb | |||
| 5cd342d521 | |||
| b4801c2dd3 | |||
| 90d8aae51a | |||
| 15063ca0b4 | |||
| f7dfeff256 | |||
| 84fc6b8d1b | |||
| ee7f0cdb42 | |||
| 3ffa5608d8 | |||
| a5500aeeff | |||
| 170614baf1 | |||
| b466b6494a | |||
| 9acad5461e | |||
| 1f0f7eee1a | |||
| 0d009b34e2 | |||
| 5fc04f14b7 | |||
| a09b636e1a | |||
| 356f8f5d6c | |||
| 96aa2407d9 | |||
| e856172d60 | |||
| ab62a3d118 | |||
| a3c0aed63e | |||
| 1ddde12959 | |||
| f1b4652c77 | |||
| 261b44522d | |||
| 6f20a75f9b | |||
| 2d5f0691c2 | |||
| e8a515dac4 | |||
| acc7149537 | |||
| 4ede37d9ad | |||
| 4bd069430b | |||
| 22152b1ba3 | |||
| 572325e23f | |||
| 8d466ebdd5 | |||
| af5744f8ab | |||
| c7549bbe48 | |||
| 9cac664679 | |||
| c08f4ace80 | |||
| c1df2af992 | |||
| a7f35a5d6d | |||
| e610948307 | |||
| 8c00ee21c7 | |||
| 2500914498 | |||
| fb811804d2 | |||
| 0134281b8c | |||
| 4dbb3190ff | |||
| 2299824ad9 | |||
| a77d4ceac0 | |||
| 11c40c6aca | |||
| 37134950a5 | |||
| f4d07ef9a1 | |||
| 74cc76b369 | |||
| a65474284a | |||
| 2f67c26a45 | |||
| ab8a86e794 | |||
| 727a0760da | |||
| 983a9d92be | |||
| 21ab0d78bb | |||
| 107abda97e | |||
| 2b033131e0 | |||
| f88e82a777 | |||
| 3c0806d8fd | |||
| 4621846ec4 | |||
| d4d936ee57 | |||
| b6b73fdd0c | |||
| 2a5b6970e9 | |||
| 59427bcf2f | |||
| 163c1174db | |||
| 3162fedd3b | |||
| a05a279508 | |||
| a3985f1138 | |||
| 7cabe4efef | |||
| d703fc27f8 | |||
| f65650fb6d | |||
| 51afc992d4 | |||
| a8e6e1c2f7 | |||
| 19061cd10c | |||
| b2d1a9ea10 | |||
| fcded9294c | |||
| c3ecd1bec8 | |||
| 4c4399c9bb | |||
| e16bdd9546 | |||
| 5054c46385 | |||
| 42abb7adff | |||
| e4578593d1 | |||
| 04933f2d9f | |||
| 154f415f92 | |||
| 2435ef7961 | |||
| f6e27cd39a | |||
| f3378dd610 | |||
| 14d7678624 | |||
| e57a53a92e | |||
| 02d9f39845 | |||
| a2bea8601a | |||
| 27f123f7d9 | |||
| 0db7054518 | |||
| 861dd8ef17 | |||
| 7bc254e17e | |||
| 1daf0b7fdf | |||
| 308202a1df | |||
| 9b4f907db6 | |||
| 993bcc6a14 | |||
| 2ec54bc429 | |||
| 7e8d19606a | |||
| dd67f28745 | |||
| 6e0223a9b1 | |||
| 0eaaf66748 | |||
| 633406c05b | |||
| 2357df83be | |||
| e8eff1baa1 | |||
| 714039bae5 | |||
| 65057473c6 | |||
| 94727f40bb | |||
| 98fdd9dcdf | |||
| 08c16d6d86 | |||
| 8a6c926ea7 | |||
| fc83c7b514 | |||
| 947d4401a6 | |||
| 4eb9935f8e | |||
| 2940e95b64 | |||
| a3db6c9453 | |||
| 1b223d3891 | |||
| 0d171490c3 | |||
| 5114a8118c | |||
| fd4ce402d7 | |||
| 153e931490 | |||
| 536350380f | |||
| ab58f3ffcb | |||
| 16669a0365 | |||
| 2bcbb53c49 | |||
| 326b7008a9 | |||
| 8d5c90e0ed | |||
| 9c7dec60a9 | |||
| 6fb6729beb | |||
| e0c5789cee | |||
| 0fa7dc7982 | |||
| 6bd8a15c7a | |||
| 9bf4b504b3 | |||
| 3959c85111 | |||
| cb35133843 | |||
| 084c202654 | |||
| 3ffd9beca0 | |||
| 3f8a2c511a | |||
| 1536860e59 | |||
| 268e12a5eb | |||
| 16e0e98943 | |||
| f03a0042e8 | |||
| e266a0f2dc | |||
| e0cf527304 | |||
| ed6d0936ca | |||
| d5983433c5 | |||
| df4cb434b2 | |||
| 857a8621ec | |||
| 7f395024a0 | |||
| 84a638a41f | |||
| dd848e76e8 | |||
| 0dbb662b4c | |||
| ccebd98735 | |||
| 231f1ddf9a | |||
| 143130eaae | |||
| 56f0557d94 | |||
| 6dc488652e | |||
| caa19f2d91 | |||
| 66ddd3c366 | |||
| b1f62a0733 | |||
| 25ff7afc33 | |||
| c8baaa5329 | |||
| 36006d703d | |||
| 2944288050 | |||
| 5f239f05a5 | |||
| 2499449c0b | |||
| 5159bcd3f4 | |||
| fdaa1a0472 | |||
| 534dafb044 | |||
| d70075c426 | |||
| b6d6f22598 | |||
| 78ad8a31e7 | |||
| d3a05520d3 | |||
| 17ff69780d | |||
| a2f6bdb44f | |||
| 8fb82d473d | |||
| a7fcafadda | |||
| 3ac8cc9363 | |||
| dccdb00bce | |||
| 078520f504 | |||
| d872a30506 | |||
| 5613fec5ef | |||
| 28b07c7ef4 | |||
| 83df3773ae | |||
| 088e8f4bf8 | |||
| c47ec6093f | |||
| bca8622640 | |||
| abb384f479 | |||
| 22f03a5fe8 | |||
| 0bc17a85fb | |||
| 602ef3bfdf | |||
| 751c2878f7 | |||
| 626fc7502c | |||
| c4cccea775 | |||
| 9bf3b8a2f2 | |||
| d5c8d316c5 | |||
| 9f6ceb1731 | |||
| afcaf56b9c | |||
| 7d207e56f5 | |||
| 3e905db906 | |||
| bd1bcab12b | |||
| 8bb192fe54 | |||
| 408eed1f3d | |||
| 6b5d12f3a1 | |||
| 372e7eca86 | |||
| 79d00ba001 | |||
| 2b595c40cd | |||
| dfc08650e7 | |||
| fc0350cb96 | |||
| 5546787f78 | |||
| 6564d37a2a | |||
| 492460cf4a | |||
| c5dc3bd256 | |||
| 3f2822dfc6 | |||
| dfbeed01a6 | |||
| 1ad67dbe59 | |||
| da1cb7b590 | |||
| 66545bf8c3 | |||
| 5541171e94 | |||
| 147d6e280e | |||
| 1b3f2e254b |
@@ -1,9 +1,13 @@
|
|||||||
name: flutter
|
name: flutter
|
||||||
|
|
||||||
# Analyze + test + build the Flutter mobile client. Only runs when the
|
# Analyze + test the Flutter mobile client; build an APK only where
|
||||||
# diff touches flutter_client/ or one of the shared web inputs the
|
# it's actually consumed. dev pushes: analyze+test+codegen only
|
||||||
# sync_shared.sh script copies. Other pushes skip — this workflow is
|
# (~3min) — the operator dev-tests via a local Android Studio build,
|
||||||
# independent from the Go/web test.yml and release.yml.
|
# not the CI artifact. main pushes: also build a debug APK as the
|
||||||
|
# native-build safety net (Gradle/manifest/plugin breakage that
|
||||||
|
# analyze+test can't catch) before any release tag. tags: signed
|
||||||
|
# release APK. Only runs when the diff touches flutter_client/ or a
|
||||||
|
# shared web input sync_shared.sh copies.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -56,6 +60,12 @@ jobs:
|
|||||||
- name: Pub get
|
- name: Pub get
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Codegen (drift, etc.)
|
||||||
|
# build_runner generates *.g.dart files (drift schemas, etc.)
|
||||||
|
# which are gitignored. Must run before analyze + test so the
|
||||||
|
# generated symbols exist.
|
||||||
|
run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
- name: Analyze
|
- name: Analyze
|
||||||
run: flutter analyze --fatal-infos
|
run: flutter analyze --fatal-infos
|
||||||
|
|
||||||
@@ -63,15 +73,52 @@ jobs:
|
|||||||
run: flutter test --reporter compact
|
run: flutter test --reporter compact
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
# main-only: native-build safety net before release tags.
|
||||||
|
# dev skips this (~7min) — operator dev-tests locally.
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
run: flutter build apk --debug
|
run: flutter build apk --debug
|
||||||
|
|
||||||
|
- name: Decode signing keystore
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
# Reconstructs the release keystore from a base64-encoded
|
||||||
|
# CI secret so every tagged build is signed with the same
|
||||||
|
# key. Without this, each CI runner would generate its own
|
||||||
|
# debug keystore and Android would refuse to upgrade an
|
||||||
|
# existing install (signature mismatch).
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
|
||||||
|
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
|
||||||
|
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||||
|
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
run: flutter build apk --release
|
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||||
|
# so PackageInfo.version matches what /api/client/version
|
||||||
|
# reports — otherwise the in-app update banner would always
|
||||||
|
# think the user is behind because pubspec.yaml's static
|
||||||
|
# version drifts from the actual release tag.
|
||||||
|
#
|
||||||
|
# ANDROID_KEYSTORE_PATH is set by the previous step;
|
||||||
|
# build.gradle.kts picks it up and signs the release APK with
|
||||||
|
# the operator's persistent keystore.
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/v}"
|
||||||
|
flutter build apk --release --build-name="${TAG}"
|
||||||
|
|
||||||
- name: Upload debug APK artifact
|
- name: Upload debug APK artifact
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
uses: forgejo/upload-artifact@v3
|
uses: forgejo/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: minstrel-debug-${{ github.sha }}
|
name: minstrel-debug-${{ github.sha }}
|
||||||
@@ -81,14 +128,15 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
# Look up the release ID for this tag (release must exist already;
|
# Look up the release ID for this tag. The release object must
|
||||||
# release.yml creates it as part of the server-side release flow).
|
# exist already — operator creates it (or release.yml will, in
|
||||||
|
# a future enhancement) before pushing the tag.
|
||||||
RELEASE_ID=$(curl -fsSL \
|
RELEASE_ID=$(curl -fsSL \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||||
if [ -z "${RELEASE_ID}" ]; then
|
if [ -z "${RELEASE_ID}" ]; then
|
||||||
@@ -96,6 +144,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
curl -fsSL \
|
curl -fsSL \
|
||||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
||||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ jobs:
|
|||||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
VERSION="${GITHUB_REF#refs/tags/}"
|
VERSION="${GITHUB_REF#refs/tags/}"
|
||||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Release build: ${VERSION} + latest"
|
echo "::notice::Release build: ${VERSION} + latest"
|
||||||
else
|
else
|
||||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||||
echo "::notice::Main-branch build: :main"
|
echo "::notice::Main-branch build: :main"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -60,9 +62,40 @@ jobs:
|
|||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
echo "${{ secrets.CI_TOKEN }}" \
|
||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
# In-app update flow (#397): on tag pushes only, fetch the APK
|
||||||
|
# that flutter.yml is attaching to this same release. flutter.yml
|
||||||
|
# runs in parallel; poll up to 15 min for the asset to appear.
|
||||||
|
# On main pushes (or if the APK never lands), client/ stays empty
|
||||||
|
# and the endpoints return 404 — graceful degradation.
|
||||||
|
- name: Fetch APK for bundled in-app update channel
|
||||||
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
APK_URL="https://git.fabledsword.com/${REPO}/releases/download/${TAG}/minstrel-${TAG}.apk"
|
||||||
|
echo "Polling ${APK_URL} (up to 15 min for flutter.yml to attach)..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-o client/minstrel.apk "$APK_URL"; then
|
||||||
|
echo "Got APK on attempt $i"
|
||||||
|
echo "${TAG}" > client/minstrel.apk.version
|
||||||
|
ls -lh client/
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "APK not ready yet (attempt $i/30), sleeping 30s..."
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
echo "::warning::APK never appeared at ${APK_URL} after 15 min — image will ship without bundled update channel"
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||||
|
--push ${{ steps.tags.outputs.args }} .
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ name: test-go
|
|||||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||||
# Flutter-only diffs don't trigger this workflow.
|
# Flutter-only diffs don't trigger this workflow.
|
||||||
#
|
#
|
||||||
# Integration tests needing Postgres/ffmpeg run locally via docker-compose;
|
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||||
# they should guard with testing.Short() so this short-mode run skips them.
|
# `integration` (full `go test -race` against an ephemeral Postgres).
|
||||||
|
#
|
||||||
|
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
||||||
|
# the runner's Docker daemon also runs the operator's dev compose stack,
|
||||||
|
# so service containers get NO published ports (collision) and no
|
||||||
|
# service-name DNS. We discover the service container by the job-scoped
|
||||||
|
# name filter via the mounted docker socket and reach it by bridge IP.
|
||||||
|
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||||
|
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||||
#
|
#
|
||||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||||
# without needing the SPA to be freshly built. Real builds happen in
|
# without needing the SPA to be freshly built. Real builds happen in
|
||||||
@@ -54,3 +62,63 @@ jobs:
|
|||||||
|
|
||||||
- name: go test (short, race)
|
- name: go test (short, race)
|
||||||
run: go test -short -race ./...
|
run: go test -short -race ./...
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: go-ci
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: minstrel
|
||||||
|
POSTGRES_PASSWORD: minstrel
|
||||||
|
POSTGRES_DB: minstrel_test
|
||||||
|
# No `ports:` — the runner shares the operator's dev compose
|
||||||
|
# Docker daemon; publishing a fixed host port collides.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
# Discover THIS job's Postgres service container via the
|
||||||
|
# mounted docker socket. act_runner attaches the job
|
||||||
|
# container and its service container(s) to a shared per-job
|
||||||
|
# network, so scope discovery to a postgres that sits on a
|
||||||
|
# network THIS job container is also on. The old
|
||||||
|
# `--filter name=integration` matched EVERY concurrent
|
||||||
|
# integration run's postgres (a dev push + the main-merge run
|
||||||
|
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||||
|
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||||
|
# this job's network; skip it explicitly as belt-and-suspenders
|
||||||
|
# (a wrong target would truncate real data).
|
||||||
|
SELF=$(cat /etc/hostname)
|
||||||
|
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||||
|
test -n "$SELF_NETS"
|
||||||
|
echo "self ($SELF) networks: $SELF_NETS"
|
||||||
|
PG_ID=""
|
||||||
|
PG_NAME=""
|
||||||
|
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||||
|
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||||
|
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||||
|
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||||
|
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||||
|
echo "selected postgres: $PG_ID $PG_NAME"
|
||||||
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||||
|
test -n "$PG_IP"
|
||||||
|
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||||
|
|
||||||
|
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||||
|
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||||
|
|
||||||
|
# Apply embedded migrations to the fresh test DB, then run the
|
||||||
|
# full suite (no -short → integration tests execute). -p 1:
|
||||||
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
|
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||||
|
# package execution (the documented local invocation too).
|
||||||
|
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||||
|
go test -p 1 -race ./...
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
|
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||||
|
# tag releases; never committed. README in client/ explains the flow.
|
||||||
|
client/minstrel.apk
|
||||||
|
client/minstrel.apk.version
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -15,7 +15,13 @@ COPY . .
|
|||||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||||
COPY --from=web /web/build ./web/build
|
COPY --from=web /web/build ./web/build
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||||
|
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||||
|
# /healthz for operator-side image-version verification.
|
||||||
|
ARG MINSTREL_VERSION=dev
|
||||||
|
RUN go build -trimpath \
|
||||||
|
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||||
|
-o /out/minstrel ./cmd/minstrel
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -33,9 +39,15 @@ COPY config.example.yaml /etc/smartmusic/config.yaml
|
|||||||
# A non-writable path at this location silently breaks every downstream
|
# A non-writable path at this location silently breaks every downstream
|
||||||
# cache, so we create + chown it once at image build. Operators mount a
|
# cache, so we create + chown it once at image build. Operators mount a
|
||||||
# named volume on top to persist across container recreates.
|
# named volume on top to persist across container recreates.
|
||||||
RUN mkdir -p /app/data && chown -R minstrel:minstrel /app
|
RUN mkdir -p /app/data /app/client && chown -R minstrel:minstrel /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# In-app update channel (#397). client/ in the build context holds
|
||||||
|
# minstrel.apk + minstrel.apk.version (populated by release.yml on tag
|
||||||
|
# pushes; .gitkeep + README otherwise). Endpoints return 404 when the
|
||||||
|
# APK files aren't present, so non-tag images degrade gracefully.
|
||||||
|
COPY --chown=minstrel:minstrel client/ /app/client/
|
||||||
|
|
||||||
USER minstrel
|
USER minstrel
|
||||||
EXPOSE 4533
|
EXPOSE 4533
|
||||||
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
ENV MINSTREL_STORAGE_DATA_DIR=/app/data
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: generate test test-short lint build
|
.PHONY: generate test test-short test-integration lint build
|
||||||
|
|
||||||
SQLC_VERSION := 1.31.1
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
@@ -11,6 +11,16 @@ test:
|
|||||||
test-short:
|
test-short:
|
||||||
go test -short -race ./...
|
go test -short -race ./...
|
||||||
|
|
||||||
|
# Full suite incl. integration tests, against the dedicated minstrel_test
|
||||||
|
# DB so a run never truncates the dev `minstrel` DB (Fable #339). Ensures
|
||||||
|
# the test DB exists (idempotent — createdb errors if present, ignored).
|
||||||
|
test-integration:
|
||||||
|
docker compose up -d postgres
|
||||||
|
-docker compose exec -T postgres createdb -U minstrel minstrel_test
|
||||||
|
# -p 1: integration packages share one test DB and each TRUNCATEs it;
|
||||||
|
# concurrent package binaries deadlock on TRUNCATE. Serialize packages.
|
||||||
|
MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel_test?sslmode=disable go test -p 1 -race ./...
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
# compose.yaml
|
# compose.yaml
|
||||||
services:
|
services:
|
||||||
minstrel:
|
minstrel:
|
||||||
image: git.fabledsword.com/bvandeusen/minstrel:v1.0.0
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
- ./music:/music:ro
|
- ./music:/music:ro
|
||||||
@@ -48,7 +48,7 @@ volumes:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Watch `docker compose logs minstrel` on first start — a one-time admin password is printed to stderr. Sign in at `http://localhost:4533` with username `admin` and that password, then change it under Settings.
|
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||||
|
|
||||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||||
|
|
||||||
@@ -59,7 +59,6 @@ Most operators only need the env vars in the quickstart above. A few extras wort
|
|||||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
- `MINSTREL_AUTH_ADMIN_USERNAME` / `MINSTREL_AUTH_ADMIN_PASSWORD` — bootstrap admin credentials. Username defaults to `admin`; leave the password unset to have the server generate one and print it to stderr on first start.
|
|
||||||
|
|
||||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
|
|
||||||
@@ -84,6 +83,16 @@ Two concurrent dev processes:
|
|||||||
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
1. **Backend:** `docker compose up` — Postgres + Minstrel on `:4533`.
|
||||||
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
2. **Frontend:** `cd web && npm install && npm run dev` — Vite dev server on `:5173` with HMR. The Vite server proxies `/api/*` and `/rest/*` to `:4533` so session cookies work.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit + race (no DB): `make test-short`.
|
||||||
|
- Full suite incl. integration tests: `make test-integration`. This runs
|
||||||
|
against a dedicated `minstrel_test` database so a test run never
|
||||||
|
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||||
|
brings up the compose Postgres and creates the test DB if missing.
|
||||||
|
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||||
|
job with its own ephemeral Postgres (`.forgejo/workflows/test-go.yml`).
|
||||||
|
|
||||||
### Production build
|
### Production build
|
||||||
|
|
||||||
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
|
`docker build -t minstrel .` runs the SvelteKit build inside a `node` stage, copies the output into the `golang` stage, and `//go:embed`s it into the final binary. The container serves the SPA from `/` alongside the API surfaces; no separate static-file server is required.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runMigrate applies the embedded migrations and exits. The server also
|
||||||
|
// auto-migrates on startup (main.go); this standalone path exists for CI
|
||||||
|
// (provision a fresh DB before the integration suite) and operators who
|
||||||
|
// want to migrate without starting the server.
|
||||||
|
func runMigrate(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
||||||
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init logger: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(cfg.Database.URL, logger); err != nil {
|
||||||
|
return fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("minstrel: migrations applied")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAdmin dispatches `minstrel admin <subcommand>`.
|
||||||
|
func runAdmin(args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "reset-password":
|
||||||
|
return adminResetPassword(args[1:])
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown admin subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminResetPassword resets a user's credentials. It updates BOTH
|
||||||
|
// password_hash (bcrypt, for /api/auth/login) and subsonic_password
|
||||||
|
// (plaintext, required for Subsonic t+s token verification) so neither
|
||||||
|
// auth path is left stale. Recovers a locked-out operator when the
|
||||||
|
// bootstrap password was missed or the DB volume was recreated (Fable
|
||||||
|
// #321) without DB surgery.
|
||||||
|
func adminResetPassword(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError)
|
||||||
|
configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||||
|
username := fs.String("user", "admin", "username to reset")
|
||||||
|
password := fs.String("password", "", "new password; if empty a strong one is generated and printed")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Database.URL == "" {
|
||||||
|
return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Open(ctx, cfg.Database.URL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
user, err := q.GetUserByUsername(ctx, *username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("look up user %q: %w", *username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := *password
|
||||||
|
generated := false
|
||||||
|
if pw == "" {
|
||||||
|
b := make([]byte, 18)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Errorf("generate password: %w", err)
|
||||||
|
}
|
||||||
|
pw = base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
generated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{
|
||||||
|
ID: user.ID,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("update password_hash: %w", err)
|
||||||
|
}
|
||||||
|
sp := pw
|
||||||
|
if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{
|
||||||
|
ID: user.ID,
|
||||||
|
SubsonicPassword: &sp,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("update subsonic_password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if generated {
|
||||||
|
fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("minstrel: password for %q reset.\n", *username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+69
-14
@@ -11,11 +11,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
@@ -25,9 +26,26 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "admin":
|
||||||
|
if err := runAdmin(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "minstrel admin: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "migrate":
|
||||||
|
if err := runMigrate(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "minstrel migrate: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
fmt.Fprintf(os.Stderr, "minstrel: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -67,10 +85,6 @@ func run() error {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
if err := auth.Bootstrap(ctx, pool, cfg.Auth.AdminBootstrap, logger); err != nil {
|
|
||||||
return fmt.Errorf("bootstrap admin: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
scanner := library.New(pool, logger, cfg.Library.ScanPaths)
|
||||||
|
|
||||||
contact := cfg.Library.ContactEmail
|
contact := cfg.Library.ContactEmail
|
||||||
@@ -108,8 +122,8 @@ func run() error {
|
|||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
library.RunScanConfig{
|
library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -125,8 +139,8 @@ func run() error {
|
|||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
library.RunScanConfig{
|
library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -151,9 +165,49 @@ func run() error {
|
|||||||
// import requests and reconciles them against the library. Short-circuits
|
// import requests and reconciles them against the library. Short-circuits
|
||||||
// to no-op when lidarr_config.enabled = false.
|
// to no-op when lidarr_config.enabled = false.
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"))
|
// Live-event bus shared between SSE subscribers (api.Mount) and
|
||||||
|
// background workers that publish (reconciler today; scanner later).
|
||||||
|
// Constructed before any service that publishes so they all share the
|
||||||
|
// same instance.
|
||||||
|
bus := eventbus.New()
|
||||||
|
// Scan-run lifecycle events use a package-level setter rather than
|
||||||
|
// threading the bus through RunScan + TryStartScan + the Scheduler
|
||||||
|
// + every test caller. Per-process singleton, set once at startup.
|
||||||
|
library.SetEventBus(bus)
|
||||||
|
// Per-tick Lidarr client factory: re-reads config so an admin save
|
||||||
|
// takes effect without a restart, returning nil while Lidarr is
|
||||||
|
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
|
||||||
|
// reconciler uses it to (re)send unconfirmed adds.
|
||||||
|
lidarrClientFn := func() *lidarr.Client {
|
||||||
|
c, cerr := lidarrCfg.Get(ctx)
|
||||||
|
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return lidarr.NewClient(c.BaseURL, c.APIKey)
|
||||||
|
}
|
||||||
|
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
|
||||||
go lidarrReconciler.Run(ctx)
|
go lidarrReconciler.Run(ctx)
|
||||||
|
|
||||||
|
// library_changes compactor (#357 follow-up). Daily tick; deletes
|
||||||
|
// rows older than the configured retention so the change-log table
|
||||||
|
// doesn't grow unbounded. Clients that drop offline longer than
|
||||||
|
// retention hit the /api/library/sync 410 fallback and resync.
|
||||||
|
libraryChangesCompactor := syncpkg.NewCompactor(pool, logger.With("component", "library_changes_compactor"))
|
||||||
|
go libraryChangesCompactor.Run(ctx)
|
||||||
|
|
||||||
|
// Per-user system-playlist scheduler (#392 Half B). Fires each
|
||||||
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
|
// of this arc).
|
||||||
|
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||||
|
}
|
||||||
|
if err := playlistScheduler.Start(ctx); err != nil {
|
||||||
|
return fmt.Errorf("start playlist scheduler: %w", err)
|
||||||
|
}
|
||||||
|
defer playlistScheduler.Stop()
|
||||||
|
|
||||||
// Ensure DataDir exists before any service tries to write into it.
|
// Ensure DataDir exists before any service tries to write into it.
|
||||||
// Fatal on failure: a non-writable data_dir silently breaks every
|
// Fatal on failure: a non-writable data_dir silently breaks every
|
||||||
// downstream cache (playlist covers, artist art, album-cover fallback)
|
// downstream cache (playlist covers, artist art, album-cover fallback)
|
||||||
@@ -168,8 +222,8 @@ func run() error {
|
|||||||
|
|
||||||
scanCfg := library.RunScanConfig{
|
scanCfg := library.RunScanConfig{
|
||||||
BackfillCap: 5000,
|
BackfillCap: 5000,
|
||||||
EnrichCap: cfg.Library.CoverArtBackfillCap,
|
EnrichCap: -1, // #388: no global cap; per-provider rate limits
|
||||||
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap,
|
ArtistEnrichCap: -1,
|
||||||
DataDir: cfg.Storage.DataDir,
|
DataDir: cfg.Storage.DataDir,
|
||||||
}
|
}
|
||||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||||
@@ -177,8 +231,9 @@ func run() error {
|
|||||||
scheduler.Start(ctx)
|
scheduler.Start(ctx)
|
||||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||||
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"), cfg.Storage.DataDir)
|
srv.Bus = bus
|
||||||
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ log:
|
|||||||
level: "info" # debug | info | warn | error
|
level: "info" # debug | info | warn | error
|
||||||
format: "text" # text | json
|
format: "text" # text | json
|
||||||
|
|
||||||
auth:
|
|
||||||
# One-shot admin bootstrap. Applied only when the users table is empty.
|
|
||||||
# Username defaults to "admin" when unset — you can omit this section
|
|
||||||
# entirely for a working bootstrap. Leave password blank to have the server
|
|
||||||
# generate one and log it to stderr on first start.
|
|
||||||
# Env: MINSTREL_AUTH_ADMIN_USERNAME, MINSTREL_AUTH_ADMIN_PASSWORD
|
|
||||||
admin_bootstrap:
|
|
||||||
username: "admin"
|
|
||||||
password: ""
|
|
||||||
|
|
||||||
library:
|
library:
|
||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Runs once, only on a fresh Postgres data volume (Docker entrypoint
|
||||||
|
-- initdb hook). Creates the dedicated integration-test database so
|
||||||
|
-- `go test` never truncates the operator's dev `minstrel` DB (Fable
|
||||||
|
-- #339). For an already-initialised volume this script does NOT run;
|
||||||
|
-- `make test-integration` creates the DB idempotently instead.
|
||||||
|
CREATE DATABASE minstrel_test OWNER minstrel;
|
||||||
+11
-7
@@ -1,10 +1,14 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
# Local development environment for Minstrel.
|
# Local development environment for Minstrel.
|
||||||
# Not used by CI — integration tests run against this compose stack manually:
|
#
|
||||||
# docker compose up -d postgres
|
# Integration tests run against a SEPARATE database (minstrel_test) so a
|
||||||
# MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel?sslmode=disable \
|
# test run never truncates the dev `minstrel` DB (Fable #339). Use the
|
||||||
# go test ./...
|
# Makefile target, which ensures the test DB exists then points the
|
||||||
|
# tests at it:
|
||||||
|
# make test-integration
|
||||||
|
# (CI runs the same suite against its own ephemeral Postgres service —
|
||||||
|
# see .forgejo/workflows/test-go.yml.)
|
||||||
#
|
#
|
||||||
# Full stack (server + db):
|
# Full stack (server + db):
|
||||||
# docker compose up --build
|
# docker compose up --build
|
||||||
@@ -22,6 +26,9 @@ services:
|
|||||||
# - "5432:5432"
|
# - "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- minstrel-pgdata:/var/lib/postgresql/data
|
- minstrel-pgdata:/var/lib/postgresql/data
|
||||||
|
# Creates minstrel_test on a fresh volume (Fable #339). No-op on
|
||||||
|
# an existing volume — make test-integration ensures it instead.
|
||||||
|
- ./deploy/initdb:/docker-entrypoint-initdb.d:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
|
test: ["CMD-SHELL", "pg_isready -U minstrel -d minstrel"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -39,9 +46,6 @@ services:
|
|||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
MINSTREL_LIBRARY_SCAN_ON_STARTUP: "true"
|
||||||
MINSTREL_STORAGE_DATA_DIR: /app/data
|
MINSTREL_STORAGE_DATA_DIR: /app/data
|
||||||
# MINSTREL_AUTH_ADMIN_PASSWORD left unset on purpose — bootstrap
|
|
||||||
# generates one and prints it to stderr on first boot. Watch the logs
|
|
||||||
# of the minstrel service the first time you bring the stack up.
|
|
||||||
networks:
|
networks:
|
||||||
- minstrel
|
- minstrel
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -43,3 +43,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# drift codegen output (regenerated by build_runner; CI runs build_runner)
|
||||||
|
*.g.dart
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,38 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real release signing config — populated only when CI exports
|
||||||
|
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
|
||||||
|
// matching password/alias env vars. Without these (local
|
||||||
|
// `flutter build apk --release` runs), the release build falls
|
||||||
|
// through to the debug keystore so local builds still work.
|
||||||
|
//
|
||||||
|
// Why this matters: Flutter's default `flutter run --release` and
|
||||||
|
// CI's earlier setup both signed with the per-machine debug
|
||||||
|
// keystore (~/.android/debug.keystore). Every CI runner generated
|
||||||
|
// its own debug key, so consecutive release APKs had different
|
||||||
|
// signatures and Android refused to upgrade an existing install.
|
||||||
|
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
|
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystorePath)
|
||||||
|
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
|
||||||
|
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||||
|
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
// Local-only fallback so `flutter run --release` works
|
||||||
|
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Used in all build flavours (debug / profile / release). -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<!-- Android 13+ requires this to be declared AND prompted at runtime
|
||||||
|
for the media notification (now-playing tile) to appear. Audio
|
||||||
|
playback works without it; only the system-tray surface breaks. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- In-app update flow (#397). First update attempt prompts the
|
||||||
|
user to enable "Install unknown apps" for Minstrel in
|
||||||
|
Settings → Apps → Special access. One-time grant; persists. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<application
|
<application
|
||||||
android:label="minstrel"
|
android:label="minstrel"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -49,6 +60,20 @@
|
|||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<!-- FileProvider for the in-app update flow (#397). Maps the
|
||||||
|
cache directory (where dio.download writes the APK) to a
|
||||||
|
content:// URI so the PackageInstaller intent can read it
|
||||||
|
across the process boundary. file:// URIs are blocked
|
||||||
|
since Android 7. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
+67
-2
@@ -1,5 +1,70 @@
|
|||||||
package com.fabledsword.minstrel
|
package com.fabledsword.minstrel
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
// Must extend AudioServiceActivity (not FlutterActivity) so the
|
||||||
|
// audio_service plugin can wire its FlutterEngine through. Without this
|
||||||
|
// the platform call from AudioService.init() throws PlatformException
|
||||||
|
// "The Activity class declared in your AndroidManifest.xml is wrong",
|
||||||
|
// which on first start cascades into a flutter_cache_manager sqlite
|
||||||
|
// EXCLUSIVE-lock crash because the engine partially re-initialises.
|
||||||
|
class MainActivity : AudioServiceActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val INSTALLER_CHANNEL = "com.fabledsword.minstrel/installer"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// In-app update channel (#397). Dart calls install(path) with
|
||||||
|
// the cache-dir APK path; we hand it to Android's
|
||||||
|
// PackageInstaller via FileProvider + ACTION_VIEW.
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"install" -> {
|
||||||
|
val path = call.argument<String>("path")
|
||||||
|
if (path == null) {
|
||||||
|
result.error("missing_path", "path argument required", null)
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
installApk(path)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("install_failed", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installApk(path: String) {
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw IllegalStateException("apk not found at $path")
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
// NEW_TASK: PackageInstaller runs in its own task.
|
||||||
|
// GRANT_READ_URI_PERMISSION: hands the content:// URI to
|
||||||
|
// the installer process, which lacks our app's read perms
|
||||||
|
// by default.
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<!-- Lucide "heart" (filled) — same path verbatim from lucide-icons/lucide -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<!-- Lucide "heart" (outline) — path verbatim from lucide-icons/lucide -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M2 9.5a5.5 5.5 0 0 1 9.591 -3.676 .56 .56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29 -1.5 4 -3 5.5l-5.492 5.313a2 2 0 0 1 -3 .019L5 15c-1.5 -1.5 -3 -3.2 -3 -5.5"/>
|
||||||
|
</vector>
|
||||||
@@ -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,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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: LucideIcons.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: LucideIcons.triangle_alert,
|
||||||
|
title: 'Quarantine',
|
||||||
|
subtitle: 'Resolve scan failures',
|
||||||
|
count: c.quarantine,
|
||||||
|
onTap: () => context.push('/admin/quarantine'),
|
||||||
|
),
|
||||||
|
AdminSectionCard(
|
||||||
|
key: const Key('admin_card_users'),
|
||||||
|
icon: LucideIcons.users,
|
||||||
|
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,67 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../shared/live_events_provider.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>()!;
|
||||||
|
// #402 wire-up: any quarantine event (flag from a user / admin
|
||||||
|
// resolve / file delete / lidarr delete) refreshes the admin queue.
|
||||||
|
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||||
|
final e = next.asData?.value;
|
||||||
|
if (e == null) return;
|
||||||
|
if (e.kind.startsWith('quarantine.')) {
|
||||||
|
ref.invalidate(adminQuarantineProvider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../models/admin_user.dart';
|
||||||
|
import '../shared/live_events_provider.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>()!;
|
||||||
|
// #402 wire-up: invalidate the admin requests list when a user
|
||||||
|
// creates/cancels/admin approves/rejects/reconciler completes —
|
||||||
|
// request.status_changed covers all of them.
|
||||||
|
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||||
|
final e = next.asData?.value;
|
||||||
|
if (e?.kind == 'request.status_changed') {
|
||||||
|
ref.invalidate(adminRequestsProvider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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,201 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.plus, 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,102 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.ellipsis_vertical, 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,59 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.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,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.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(LucideIcons.trash_2, color: fs.oxblood),
|
||||||
|
tooltip: 'Revoke',
|
||||||
|
onPressed: onRevoke,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet that requires the user to type [confirmWord]
|
||||||
|
/// (default "DELETE") before the confirm button enables. Used for
|
||||||
|
/// destructive actions like quarantine delete-file / delete-via-Lidarr
|
||||||
|
/// and user delete.
|
||||||
|
///
|
||||||
|
/// Returns true if the user confirmed, false (or null → false) otherwise.
|
||||||
|
class TypedConfirmSheet extends StatefulWidget {
|
||||||
|
const TypedConfirmSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.confirmWord = 'DELETE',
|
||||||
|
this.confirmLabel = 'Delete',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final String confirmWord;
|
||||||
|
final String confirmLabel;
|
||||||
|
|
||||||
|
static Future<bool> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
String confirmWord = 'DELETE',
|
||||||
|
String confirmLabel = 'Delete',
|
||||||
|
}) async {
|
||||||
|
final ok = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => TypedConfirmSheet(
|
||||||
|
title: title,
|
||||||
|
message: message,
|
||||||
|
confirmWord: confirmWord,
|
||||||
|
confirmLabel: confirmLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ok ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TypedConfirmSheet> createState() => _TypedConfirmSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypedConfirmSheetState extends State<TypedConfirmSheet> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
bool _enabled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
color: fs.iron,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontFamily: 'Fraunces',
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(widget.message, style: TextStyle(color: fs.ash)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Type ${widget.confirmWord} to confirm:',
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
key: const Key('typed_confirm_input'),
|
||||||
|
controller: _controller,
|
||||||
|
autofocus: true,
|
||||||
|
style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'),
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||||
|
onChanged: (v) =>
|
||||||
|
setState(() => _enabled = v == widget.confirmWord),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
key: const Key('typed_confirm_button'),
|
||||||
|
onPressed:
|
||||||
|
_enabled ? () => Navigator.pop(context, true) : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: fs.oxblood,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
child: Text(widget.confirmLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/invite.dart';
|
||||||
|
|
||||||
|
class AdminInvitesApi {
|
||||||
|
AdminInvitesApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped.
|
||||||
|
Future<List<Invite>> list() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/admin/invites');
|
||||||
|
final raw = (r.data?['invites'] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => Invite.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/invites — body is optional `{"note": "..."}`.
|
||||||
|
/// Server hardcodes the 24h TTL; admins cannot configure expiry.
|
||||||
|
/// Returns the bare invite (not enveloped).
|
||||||
|
Future<Invite> create({String? note}) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/api/admin/invites',
|
||||||
|
data: {if (note != null && note.isNotEmpty) 'note': note},
|
||||||
|
);
|
||||||
|
return Invite.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> revoke(String token) async {
|
||||||
|
await _dio.delete<void>('/api/admin/invites/$token');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/admin_quarantine_item.dart';
|
||||||
|
|
||||||
|
class AdminQuarantineApi {
|
||||||
|
AdminQuarantineApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/admin/quarantine — flat list (no envelope) of aggregated
|
||||||
|
/// quarantine rows.
|
||||||
|
Future<List<AdminQuarantineItem>> list() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/admin/quarantine');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) =>
|
||||||
|
AdminQuarantineItem.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resolve(String trackId) async {
|
||||||
|
await _dio.post<void>('/api/admin/quarantine/$trackId/resolve');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteFile(String trackId) async {
|
||||||
|
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-file');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteViaLidarr(String trackId) async {
|
||||||
|
await _dio.post<void>('/api/admin/quarantine/$trackId/delete-via-lidarr');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/admin_request.dart';
|
||||||
|
|
||||||
|
class AdminRequestsApi {
|
||||||
|
AdminRequestsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/admin/requests — flat list of pending requests.
|
||||||
|
/// Server defaults to ?status=pending; we don't override.
|
||||||
|
Future<List<AdminRequest>> list() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/admin/requests');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> approve(String id) async {
|
||||||
|
await _dio.post<void>('/api/admin/requests/$id/approve');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reject(String id) async {
|
||||||
|
await _dio.post<void>('/api/admin/requests/$id/reject');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/admin_user.dart';
|
||||||
|
|
||||||
|
class AdminUsersApi {
|
||||||
|
AdminUsersApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here.
|
||||||
|
Future<List<AdminUser>> list() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/admin/users');
|
||||||
|
final raw = (r.data?['users'] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => AdminUser.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAdmin(String id, bool isAdmin) async {
|
||||||
|
await _dio.put<void>(
|
||||||
|
'/api/admin/users/$id/admin',
|
||||||
|
data: {'is_admin': isAdmin},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server's body field is `auto_approve` (NOT `auto_approve_requests`)
|
||||||
|
/// — different from the response field name. Verified against
|
||||||
|
/// internal/api/admin_users.go `adminAutoApproveReq` (May 2026).
|
||||||
|
Future<void> setAutoApprove(String id, bool autoApprove) async {
|
||||||
|
await _dio.put<void>(
|
||||||
|
'/api/admin/users/$id/auto-approve',
|
||||||
|
data: {'auto_approve': autoApprove},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin supplies the new password; server returns 204. There is no
|
||||||
|
/// server-generated-password mode.
|
||||||
|
Future<void> resetPassword(String id, String newPassword) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/admin/users/$id/reset-password',
|
||||||
|
data: {'password': newPassword},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String id) async {
|
||||||
|
await _dio.delete<void>('/api/admin/users/$id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/artist_suggestion.dart';
|
||||||
|
import '../../models/lidarr.dart';
|
||||||
|
|
||||||
|
class DiscoverApi {
|
||||||
|
DiscoverApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/discover/suggestions — out-of-library artist suggestions
|
||||||
|
/// (ListenBrainz-derived; image_url resolved on-demand from Lidarr,
|
||||||
|
/// may be empty). The server already filters in-library and
|
||||||
|
/// non-terminal-request candidates.
|
||||||
|
Future<List<ArtistSuggestion>> listSuggestions() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/discover/suggestions');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) =>
|
||||||
|
ArtistSuggestion.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/lidarr/search?q=...&kind=artist|album|track. Server has a
|
||||||
|
/// 60s LRU cache for repeat queries so re-typing the same string in
|
||||||
|
/// quick succession is cheap.
|
||||||
|
Future<List<LidarrSearchResult>> search(
|
||||||
|
String query,
|
||||||
|
LidarrRequestKind kind,
|
||||||
|
) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
'/api/lidarr/search',
|
||||||
|
queryParameters: {'q': query, 'kind': kind.wire},
|
||||||
|
);
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) =>
|
||||||
|
LidarrSearchResult.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/requests. Returns the newly-created LidarrRequest body
|
||||||
|
/// — we don't expose a typed wrapper for that yet on mobile (admin
|
||||||
|
/// reviews requests; user just kicks them off), so we discard the
|
||||||
|
/// response and surface success/failure to the caller.
|
||||||
|
Future<void> createRequest({
|
||||||
|
required LidarrRequestKind kind,
|
||||||
|
required String artistMbid,
|
||||||
|
required String artistName,
|
||||||
|
String? albumMbid,
|
||||||
|
String? albumTitle,
|
||||||
|
String? trackMbid,
|
||||||
|
String? trackTitle,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'kind': kind.wire,
|
||||||
|
'lidarr_artist_mbid': artistMbid,
|
||||||
|
'artist_name': artistName,
|
||||||
|
};
|
||||||
|
if (albumMbid != null && albumMbid.isNotEmpty) {
|
||||||
|
body['lidarr_album_mbid'] = albumMbid;
|
||||||
|
}
|
||||||
|
if (trackMbid != null && trackMbid.isNotEmpty) {
|
||||||
|
body['lidarr_track_mbid'] = trackMbid;
|
||||||
|
}
|
||||||
|
if (albumTitle != null && albumTitle.isNotEmpty) {
|
||||||
|
body['album_title'] = albumTitle;
|
||||||
|
}
|
||||||
|
if (trackTitle != null && trackTitle.isNotEmpty) {
|
||||||
|
body['track_title'] = trackTitle;
|
||||||
|
}
|
||||||
|
await _dio.post<Map<String, dynamic>>('/api/requests', data: body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Thin client for POST /api/events — the play-event lifecycle the
|
||||||
|
/// server uses for history, recommendation scoring, ListenBrainz
|
||||||
|
/// scrobbles, and (since #415) system-playlist rotation.
|
||||||
|
///
|
||||||
|
/// Mirrors the web events dispatcher's three calls. Best-effort by
|
||||||
|
/// contract: callers swallow errors — a missed event is acceptable
|
||||||
|
/// per the server spec's v1 stance, and the server's
|
||||||
|
/// auto-close-prior-open keeps history sane even if an ended/skipped
|
||||||
|
/// is lost.
|
||||||
|
class EventsApi {
|
||||||
|
EventsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// POST play_started. Returns the server's play_event_id (used to
|
||||||
|
/// close the row later), or null if the call failed / response was
|
||||||
|
/// malformed. `source` tags the originating system playlist
|
||||||
|
/// ('for_you' | 'discover') so the server advances that rotation;
|
||||||
|
/// omit for library / user-playlist / radio plays.
|
||||||
|
Future<String?> playStarted({
|
||||||
|
required String trackId,
|
||||||
|
required String clientId,
|
||||||
|
String? source,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_started',
|
||||||
|
'track_id': trackId,
|
||||||
|
'client_id': clientId,
|
||||||
|
if (source != null && source.isNotEmpty) 'source': source,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (r.data ?? const {})['play_event_id'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playEnded({
|
||||||
|
required String playEventId,
|
||||||
|
required int durationPlayedMs,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_ended',
|
||||||
|
'play_event_id': playEventId,
|
||||||
|
'duration_played_ms': durationPlayedMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> playSkipped({
|
||||||
|
required String playEventId,
|
||||||
|
required int positionMs,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_skipped',
|
||||||
|
'play_event_id': playEventId,
|
||||||
|
'position_ms': positionMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a complete play that happened offline / on a flaky
|
||||||
|
/// connection (#426 part B). One call: the server records start+end
|
||||||
|
/// from `atIso` (the original play-start time) + durationPlayedMs,
|
||||||
|
/// applies the canonical skip rule, and advances #415 rotation when
|
||||||
|
/// source is a system playlist. Driven by the offline mutation
|
||||||
|
/// queue, never the live path.
|
||||||
|
Future<void> playOffline({
|
||||||
|
required String trackId,
|
||||||
|
required String clientId,
|
||||||
|
required String atIso,
|
||||||
|
required int durationPlayedMs,
|
||||||
|
String? source,
|
||||||
|
}) async {
|
||||||
|
await _dio.post<void>(
|
||||||
|
'/api/events',
|
||||||
|
data: {
|
||||||
|
'type': 'play_offline',
|
||||||
|
'track_id': trackId,
|
||||||
|
'client_id': clientId,
|
||||||
|
'at': atIso,
|
||||||
|
'duration_played_ms': durationPlayedMs,
|
||||||
|
if (source != null && source.isNotEmpty) 'source': source,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
import '../../models/home_data.dart';
|
import '../../models/home_data.dart';
|
||||||
|
import '../../models/home_index.dart';
|
||||||
import '../../models/track.dart';
|
import '../../models/track.dart';
|
||||||
|
|
||||||
/// LibraryApi wraps the server's native /api/* library surface.
|
/// LibraryApi wraps the server's native /api/* library surface.
|
||||||
@@ -27,6 +28,24 @@ class LibraryApi {
|
|||||||
return HomeData.fromJson(r.data ?? const {});
|
return HomeData.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/home/index — per-item rendering variant. Returns just IDs
|
||||||
|
/// per section; client hydrates each tile via the per-entity
|
||||||
|
/// endpoints. ~10× smaller than /api/home on populated libraries so
|
||||||
|
/// the cold-visit round-trip is correspondingly short.
|
||||||
|
Future<HomeIndex> getHomeIndex() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
|
||||||
|
return HomeIndex.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
|
||||||
|
/// the HydrationQueue to populate cached_tracks on a per-tile miss
|
||||||
|
/// — the existing endpoint already joins album + artist so the
|
||||||
|
/// response carries everything TrackRef needs.
|
||||||
|
Future<TrackRef> getTrack(String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
|
||||||
|
return TrackRef.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
|
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
|
||||||
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
||||||
/// it cares about, so passing the whole body is correct.
|
/// it cares about, so passing the whole body is correct.
|
||||||
@@ -35,6 +54,24 @@ class LibraryApi {
|
|||||||
return ArtistRef.fromJson(r.data ?? const {});
|
return ArtistRef.fromJson(r.data ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/artists/{id} — full response with the ArtistRef AND the
|
||||||
|
/// embedded album list, both parsed. Single round-trip variant used
|
||||||
|
/// by CacheFiller and other callers that want to populate both
|
||||||
|
/// cached_artists and cached_albums in one shot. The existing
|
||||||
|
/// getArtist / getArtistAlbums keep working for callers that only
|
||||||
|
/// need one half — they hit the same URL but the per-id Riverpod
|
||||||
|
/// caching layer dedupes.
|
||||||
|
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
|
||||||
|
String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||||||
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
|
final artist = ArtistRef.fromJson(body);
|
||||||
|
final albums = ((body['albums'] as List?) ?? const [])
|
||||||
|
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
return (artist: artist, albums: albums);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
||||||
/// that need both the artist and its albums should issue two provider
|
/// that need both the artist and its albums should issue two provider
|
||||||
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
||||||
@@ -48,6 +85,19 @@ class LibraryApi {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
|
||||||
|
/// tracks — the online source for "Shuffle all". Bare JSON array.
|
||||||
|
Future<List<TrackRef>> shuffle({int limit = 100}) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
'/api/library/shuffle',
|
||||||
|
queryParameters: {'limit': limit},
|
||||||
|
);
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||||||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||||||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/album.dart';
|
||||||
|
import '../../models/artist.dart';
|
||||||
|
import '../../models/page.dart';
|
||||||
|
|
||||||
|
/// Paged library browse endpoints. Distinct from LibraryApi (which
|
||||||
|
/// fetches Home + entity detail) so screens that just need a flat
|
||||||
|
/// list don't drag in detail-page providers.
|
||||||
|
class LibraryListsApi {
|
||||||
|
LibraryListsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||||
|
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||||
|
// not /api/library/artists. Albums use /api/library/albums for
|
||||||
|
// historical reasons; the paths aren't symmetric.
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/artists',
|
||||||
|
queryParameters: {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'sort': 'alpha',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/library/albums',
|
||||||
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/album.dart';
|
||||||
|
import '../../models/artist.dart';
|
||||||
|
import '../../models/page.dart';
|
||||||
|
import '../../models/track.dart';
|
||||||
|
|
||||||
enum LikeKind { artist, album, track }
|
enum LikeKind { artist, album, track }
|
||||||
|
|
||||||
extension LikeKindPath on LikeKind {
|
extension LikeKindPath on LikeKind {
|
||||||
@@ -22,6 +27,31 @@ class LikesApi {
|
|||||||
await _dio.delete<void>('/api/likes/${kind.path}/$id');
|
await _dio.delete<void>('/api/likes/${kind.path}/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/likes/tracks?limit=N&offset=N. Paged.
|
||||||
|
Future<Paged<TrackRef>> listTracks({int limit = 50, int offset = 0}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/likes/tracks',
|
||||||
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return Paged.fromJson(r.data ?? const {}, TrackRef.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Paged<AlbumRef>> listAlbums({int limit = 50, int offset = 0}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/likes/albums',
|
||||||
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return Paged.fromJson(r.data ?? const {}, AlbumRef.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/likes/artists',
|
||||||
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns sets of {artists, albums, tracks} the user has liked.
|
/// Returns sets of {artists, albums, tracks} the user has liked.
|
||||||
/// Server response keys (verified in internal/api/likes.go
|
/// Server response keys (verified in internal/api/likes.go
|
||||||
/// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`.
|
/// `likedIDsResponse`): `artist_ids`, `album_ids`, `track_ids`.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/history_event.dart';
|
||||||
|
import '../../models/quarantine_mine.dart';
|
||||||
|
import '../../models/system_playlists_status.dart';
|
||||||
|
|
||||||
|
/// /api/me/* endpoints — caller-scoped data (history, profile, etc.).
|
||||||
|
class MeApi {
|
||||||
|
MeApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/me/history. Paginated via offset/limit; server emits
|
||||||
|
/// `{events, has_more}` rather than the standard `Page<T>` envelope.
|
||||||
|
Future<HistoryPage> history({int limit = 50, int offset = 0}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/me/history',
|
||||||
|
queryParameters: {'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return HistoryPage.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/quarantine/mine — flat list (no envelope).
|
||||||
|
Future<List<QuarantineMineRow>> quarantineMine() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/quarantine/mine');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) =>
|
||||||
|
QuarantineMineRow.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/me/system-playlists-status. Returns the caller's most
|
||||||
|
/// recent system-playlist build state. Used by the home Playlists
|
||||||
|
/// row to choose between real and placeholder cards.
|
||||||
|
Future<SystemPlaylistsStatus> systemPlaylistsStatus() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/me/system-playlists-status',
|
||||||
|
);
|
||||||
|
return SystemPlaylistsStatus.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/me/timezone — submit the device's IANA timezone. The
|
||||||
|
/// scheduler uses this to fire the user's daily playlist build at
|
||||||
|
/// 03:00 in their local time. See AuthController._sendTimezoneIfStale
|
||||||
|
/// for the weekly cadence trigger.
|
||||||
|
Future<void> putTimezone(String timezone) async {
|
||||||
|
await _dio.put<void>('/api/me/timezone', data: {'timezone': timezone});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/playlist.dart';
|
||||||
|
|
||||||
|
/// Wire shape for GET /api/playlists. Server splits owned vs. public so
|
||||||
|
/// the UI can present them as different sections; we preserve that split
|
||||||
|
/// to give the integrations page room to grow.
|
||||||
|
class PlaylistsList {
|
||||||
|
const PlaylistsList({required this.owned, required this.public});
|
||||||
|
final List<Playlist> owned;
|
||||||
|
final List<Playlist> public;
|
||||||
|
|
||||||
|
factory PlaylistsList.empty() =>
|
||||||
|
const PlaylistsList(owned: [], public: []);
|
||||||
|
|
||||||
|
/// Concatenated view for callers that don't care about the split.
|
||||||
|
List<Playlist> get all => [...owned, ...public];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaylistsApi {
|
||||||
|
PlaylistsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/playlists?kind=user|system|all. Server returns
|
||||||
|
/// `{"owned": [...], "public": [...]}`. Owned is the caller's own
|
||||||
|
/// playlists, filtered by the kind param (default "user"). Public
|
||||||
|
/// is other users' shared playlists; not filtered by kind.
|
||||||
|
Future<PlaylistsList> list({String kind = 'user'}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/playlists',
|
||||||
|
queryParameters: {'kind': kind},
|
||||||
|
);
|
||||||
|
final body = r.data ?? const <String, dynamic>{};
|
||||||
|
List<Playlist> parse(String key) {
|
||||||
|
final raw = (body[key] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => Playlist.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
return PlaylistsList(owned: parse('owned'), public: parse('public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PlaylistDetail> get(String id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/playlists/$id');
|
||||||
|
return PlaylistDetail.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2).
|
||||||
|
/// Same shape as get() but tracks are server-ordered rotation-aware
|
||||||
|
/// (unplayed-this-rotation first; resets when exhausted). {kind} is
|
||||||
|
/// the raw system_variant. Intentionally uncached — varies per play.
|
||||||
|
Future<PlaylistDetail> systemShuffle(String variant) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/playlists/system/$variant/shuffle',
|
||||||
|
);
|
||||||
|
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},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/playlists/system/{kind}/refresh (#411 R2). Rebuilds
|
||||||
|
/// the caller's system playlists and returns the named kind's new
|
||||||
|
/// playlist id, or null when the library is empty. {kind} is the
|
||||||
|
/// raw system_variant — the server routes generically off the
|
||||||
|
/// kind registry, no hyphen mapping.
|
||||||
|
Future<String?> refreshSystem(String variant) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(
|
||||||
|
'/api/playlists/system/$variant/refresh',
|
||||||
|
data: const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
return (r.data ?? const {})['playlist_id'] as String?;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// /api/quarantine — flag a track (with reason + optional notes) and
|
||||||
|
/// unflag it. Both endpoints are user-scoped: callers can only flag
|
||||||
|
/// their own quarantine entries; admins use a separate /admin/quarantine
|
||||||
|
/// surface.
|
||||||
|
class QuarantineApi {
|
||||||
|
QuarantineApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// POST /api/quarantine. Server returns 201 with the row.
|
||||||
|
/// Reason values: bad_rip | wrong_file | wrong_tags | duplicate | other.
|
||||||
|
Future<void> flag(String trackId, String reason, {String notes = ''}) async {
|
||||||
|
await _dio.post<void>('/api/quarantine', data: {
|
||||||
|
'track_id': trackId,
|
||||||
|
'reason': reason,
|
||||||
|
'notes': notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/quarantine/{track_id}. Server returns 204.
|
||||||
|
Future<void> unflag(String trackId) async {
|
||||||
|
await _dio.delete<void>('/api/quarantine/$trackId');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/track.dart';
|
||||||
|
|
||||||
|
/// `GET /api/radio?seed_track=<uuid>&limit=<int>`. Returns the seed
|
||||||
|
/// at index 0 followed by up to `limit-1` weighted-shuffle picks
|
||||||
|
/// scored by the server's recommendation engine. The shape matches
|
||||||
|
/// what `playerActions.playTracks` expects, so a radio start is just
|
||||||
|
/// one fetch + one playTracks call.
|
||||||
|
class RadioApi {
|
||||||
|
RadioApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<List<TrackRef>> seedTrack(String trackId, {int? limit}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/radio',
|
||||||
|
queryParameters: {
|
||||||
|
'seed_track': trackId,
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final raw = (r.data?['tracks'] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/admin_request.dart';
|
||||||
|
|
||||||
|
/// User-side requests API — `/api/requests`. Server scopes results to
|
||||||
|
/// the caller; admins see only their own here, not all users'. The
|
||||||
|
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
|
||||||
|
///
|
||||||
|
/// Wire shape is identical to the admin endpoint, so the same
|
||||||
|
/// AdminRequest model is reused.
|
||||||
|
class RequestsApi {
|
||||||
|
RequestsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// GET /api/requests — caller's own requests, all statuses.
|
||||||
|
Future<List<AdminRequest>> listMine() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>('/api/requests');
|
||||||
|
final raw = r.data ?? const [];
|
||||||
|
return raw
|
||||||
|
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/requests/{id} — cancel a pending request. Server
|
||||||
|
/// returns the cancelled row body (not 204) so the caller can patch
|
||||||
|
/// local state without a refetch.
|
||||||
|
Future<AdminRequest> cancel(String id) async {
|
||||||
|
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
|
||||||
|
return AdminRequest.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/search_response.dart';
|
||||||
|
|
||||||
|
/// SearchApi wraps GET /api/search. The server runs three facets (artists,
|
||||||
|
/// albums, tracks) sharing one limit/offset pair. Each facet returns its
|
||||||
|
/// own total reflecting the full match count.
|
||||||
|
class SearchApi {
|
||||||
|
SearchApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
/// Empty / whitespace-only query is the caller's responsibility to
|
||||||
|
/// guard against — the server returns 400 bad_request for it.
|
||||||
|
Future<SearchResponse> search(
|
||||||
|
String query, {
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/api/search',
|
||||||
|
queryParameters: {
|
||||||
|
'q': query,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return SearchResponse.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../models/my_profile.dart';
|
||||||
|
|
||||||
|
class SettingsApi {
|
||||||
|
SettingsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<MyProfile> getProfile() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/me');
|
||||||
|
return MyProfile.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pass only the fields you want to change; server merges. Empty
|
||||||
|
/// strings clear; null leaves the existing value alone.
|
||||||
|
Future<MyProfile> updateProfile({String? displayName, String? email}) async {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (displayName != null) body['display_name'] = displayName;
|
||||||
|
if (email != null) body['email'] = email;
|
||||||
|
final r = await _dio.put<Map<String, dynamic>>(
|
||||||
|
'/api/me/profile',
|
||||||
|
data: body,
|
||||||
|
);
|
||||||
|
return MyProfile.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changePassword({
|
||||||
|
required String current,
|
||||||
|
required String next,
|
||||||
|
}) async {
|
||||||
|
await _dio.put<void>('/api/me/password', data: {
|
||||||
|
'current_password': current,
|
||||||
|
'new_password': next,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ListenBrainzStatus> getListenBrainz() async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('/api/me/listenbrainz');
|
||||||
|
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ListenBrainzStatus> setListenBrainzToken(String token) async {
|
||||||
|
final r = await _dio.put<Map<String, dynamic>>(
|
||||||
|
'/api/me/listenbrainz',
|
||||||
|
data: {'token': token},
|
||||||
|
);
|
||||||
|
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ListenBrainzStatus> setListenBrainzEnabled(bool enabled) async {
|
||||||
|
final r = await _dio.put<Map<String, dynamic>>(
|
||||||
|
'/api/me/listenbrainz',
|
||||||
|
data: {'enabled': enabled},
|
||||||
|
);
|
||||||
|
return ListenBrainzStatus.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,106 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import 'cache/cache_filler.dart';
|
||||||
|
import 'cache/metadata_prefetcher.dart';
|
||||||
|
import 'cache/offline_provider.dart';
|
||||||
|
import 'cache/mutation_queue.dart';
|
||||||
|
import 'cache/prefetcher.dart';
|
||||||
|
import 'cache/resume_controller.dart';
|
||||||
|
import 'cache/sync_controller.dart';
|
||||||
|
import 'player/play_events_reporter.dart';
|
||||||
|
import 'player/playback_error_reporter.dart';
|
||||||
|
import 'shared/live_events_dispatcher.dart';
|
||||||
import 'shared/routing.dart';
|
import 'shared/routing.dart';
|
||||||
import 'theme/theme_data.dart';
|
import 'theme/theme_data.dart';
|
||||||
|
import 'theme/theme_mode_provider.dart';
|
||||||
|
|
||||||
class MinstrelApp extends ConsumerWidget {
|
class MinstrelApp extends ConsumerStatefulWidget {
|
||||||
const MinstrelApp({super.key});
|
const MinstrelApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<MinstrelApp> createState() => _MinstrelAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Activate offline-mode infrastructure once the first frame ships.
|
||||||
|
// SyncController.sync() is connectivity-aware (no-ops on no auth /
|
||||||
|
// no server URL), so calling it unconditionally is safe.
|
||||||
|
// Reading prefetcherProvider runs its constructor, which wires the
|
||||||
|
// queue + settings listeners.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
ref.read(syncControllerProvider.notifier).sync();
|
||||||
|
ref.read(prefetcherProvider);
|
||||||
|
// Metadata prefetcher: when /api/home returns, fire background
|
||||||
|
// albumProvider/artistProvider reads for the top-N items in
|
||||||
|
// each section so subsequent taps are drift hits, not network
|
||||||
|
// round trips.
|
||||||
|
ref.read(metadataPrefetcherProvider);
|
||||||
|
// Live events (#392): subscribes to /api/events/stream and
|
||||||
|
// invalidates publicly-scoped providers when relevant events
|
||||||
|
// arrive. Also installs an AppLifecycleState observer for
|
||||||
|
// resume-time defensive invalidation.
|
||||||
|
ref.read(liveEventsDispatcherProvider);
|
||||||
|
// Cache filler: background sweeper that walks cached_artists
|
||||||
|
// / cached_albums for missing relations and fetches the
|
||||||
|
// per-entity detail so tapping an artist surfaces albums
|
||||||
|
// immediately (drift hit, no /api/artists/:id round-trip at
|
||||||
|
// tap time). First sweep 10s after launch; every 5 minutes
|
||||||
|
// thereafter. Throttled 200ms between requests so it never
|
||||||
|
// competes with user activity.
|
||||||
|
ref.read(cacheFillerProvider);
|
||||||
|
// Mutation replayer: drains the cached_mutations queue when
|
||||||
|
// connectivity comes back. Controllers (LikesController,
|
||||||
|
// MyQuarantineController, the add-to-playlist + request flows)
|
||||||
|
// enqueue on REST failure so user intent persists across
|
||||||
|
// network loss instead of getting rolled back.
|
||||||
|
ref.read(mutationReplayerProvider);
|
||||||
|
// Play-events reporter (#415): the Flutter client otherwise
|
||||||
|
// reports no plays at all — this feeds history, recommendation
|
||||||
|
// scoring, scrobbles, and system-playlist rotation, and is the
|
||||||
|
// path that carries the `source` tag for #415.
|
||||||
|
ref.read(playEventsReporterProvider);
|
||||||
|
// Resume-on-launch (#54): restores the last persisted session
|
||||||
|
// (paused) and persists queue/index/position on track change,
|
||||||
|
// pause, and app teardown. Pairs with the #52 idle teardown so a
|
||||||
|
// torn-down session is recoverable instead of lost.
|
||||||
|
ref.read(resumeControllerProvider);
|
||||||
|
// Playback-error reporter (#58): turns the handler's silent
|
||||||
|
// dead-track skips into a debounced/coalesced SnackBar so a
|
||||||
|
// vanished track isn't mysterious (and aids server/cache debug).
|
||||||
|
ref.read(playbackErrorReporterProvider);
|
||||||
|
// Offline marker (#427 S1): periodic /healthz reachability
|
||||||
|
// probe → offlineProvider. Read here to start the poller; S4
|
||||||
|
// gates system-playlist play + Shuffle-all on it.
|
||||||
|
ref.read(offlineProvider);
|
||||||
|
// POST_NOTIFICATIONS (Android 13+) is denied-by-default until
|
||||||
|
// requested; without it the media notification is silently
|
||||||
|
// suppressed on physical devices. One-shot, post-first-frame so
|
||||||
|
// it never blocks launch; no-op on <13 / once already decided.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
Permission.notification.request();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
|
final mode = ref.watch(themeModeProvider).value ?? AppThemeMode.system;
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Minstrel',
|
title: 'Minstrel',
|
||||||
theme: buildThemeData(),
|
scaffoldMessengerKey: scaffoldMessengerKey,
|
||||||
|
theme: buildLightTheme(),
|
||||||
|
darkTheme: buildDarkTheme(),
|
||||||
|
themeMode: mode.materialMode,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
|
||||||
const _kServerUrl = 'server_url';
|
const _kServerUrl = 'server_url';
|
||||||
const _kSessionToken = 'session_token';
|
const _kSessionToken = 'session_token';
|
||||||
const _kCurrentUser = 'current_user';
|
const _kCurrentUser = 'current_user';
|
||||||
|
const _kTzLastSentAt = 'tz_last_sent_at';
|
||||||
|
const _weeklyMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
||||||
(ref) => const FlutterSecureStorage(),
|
(ref) => const FlutterSecureStorage(),
|
||||||
@@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
_storage = ref.watch(secureStorageProvider);
|
_storage = ref.watch(secureStorageProvider);
|
||||||
final raw = await _storage.read(key: _kCurrentUser);
|
final raw = await _storage.read(key: _kCurrentUser);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
return User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
final user = User.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||||
|
// Fire-and-forget timezone send on app start with an existing
|
||||||
|
// session — no-op when last_sent_at is fresh.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
_sendTimezoneIfStale();
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setServerUrl(String url) async {
|
Future<void> setServerUrl(String url) async {
|
||||||
@@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
await _storage.write(key: _kCurrentUser, value: userJson);
|
await _storage.write(key: _kCurrentUser, value: userJson);
|
||||||
ref.invalidate(sessionTokenProvider);
|
ref.invalidate(sessionTokenProvider);
|
||||||
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
|
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
_sendTimezoneIfStale();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearSession() async {
|
Future<void> clearSession() async {
|
||||||
@@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier<User?> {
|
|||||||
ref.invalidate(sessionTokenProvider);
|
ref.invalidate(sessionTokenProvider);
|
||||||
state = const AsyncData(null);
|
state = const AsyncData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sends the device's current IANA timezone to PUT /api/me/timezone
|
||||||
|
/// when the last successful send was >7 days ago (or never).
|
||||||
|
/// Cadence persists in flutter_secure_storage so it survives app
|
||||||
|
/// restarts. Failures are swallowed: the server keeps its previous
|
||||||
|
/// value (or 'UTC' default) until the next attempt.
|
||||||
|
Future<void> _sendTimezoneIfStale() async {
|
||||||
|
try {
|
||||||
|
final lastStr = await _storage.read(key: _kTzLastSentAt);
|
||||||
|
final lastMs = lastStr == null ? 0 : int.tryParse(lastStr) ?? 0;
|
||||||
|
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (nowMs - lastMs < _weeklyMs) return;
|
||||||
|
final tz = await FlutterTimezone.getLocalTimezone();
|
||||||
|
if (tz.isEmpty) return;
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
await MeApi(dio).putTimezone(tz);
|
||||||
|
await _storage.write(key: _kTzLastSentAt, value: nowMs.toString());
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal — server falls back to UTC or last-known value.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final authControllerProvider =
|
final authControllerProvider =
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
// 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 {
|
||||||
|
/// `coverAlbumId` lets the caller pass a representative album id so
|
||||||
|
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
|
||||||
|
/// URL. cached_artists doesn't store this directly — the server
|
||||||
|
/// derives it from the most-recent album at query time — so drift
|
||||||
|
/// readers that want the cover URL join cached_albums and pass the
|
||||||
|
/// first album's id through. Empty string yields empty coverUrl,
|
||||||
|
/// matching the server's behavior for endpoints that don't carry
|
||||||
|
/// the lookup (artist detail, search, raw cached_artists row reads).
|
||||||
|
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
sortName: sortName,
|
||||||
|
coverUrl: coverAlbumId.isNotEmpty
|
||||||
|
? '/api/albums/$coverAlbumId/cover'
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ArtistRefDriftWrite on ArtistRef {
|
||||||
|
CachedArtistsCompanion toDrift() => CachedArtistsCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
sortName: sortName.isNotEmpty ? sortName : name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CachedAlbumAdapter on CachedAlbum {
|
||||||
|
/// `artistName` is supplied by the joined CachedArtists row at query time.
|
||||||
|
/// `coverUrl` is reconstructed deterministically from the album id —
|
||||||
|
/// the server emits the same shape (see internal/api/convert.go:69).
|
||||||
|
/// We don't need to persist it, so AlbumRef.coverUrl is non-empty
|
||||||
|
/// even when the row was populated from a sync that didn't carry the
|
||||||
|
/// derived URL.
|
||||||
|
AlbumRef toRef({String artistName = ''}) => AlbumRef(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
sortTitle: sortTitle,
|
||||||
|
artistId: artistId,
|
||||||
|
artistName: artistName,
|
||||||
|
coverUrl: '/api/albums/$id/cover',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AlbumRefDriftWrite on AlbumRef {
|
||||||
|
CachedAlbumsCompanion toDrift() => CachedAlbumsCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
artistId: artistId,
|
||||||
|
title: title,
|
||||||
|
sortTitle: sortTitle.isNotEmpty ? sortTitle : title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CachedTrackAdapter on CachedTrack {
|
||||||
|
/// `artistName` and `albumTitle` come from joined rows.
|
||||||
|
TrackRef toRef({String artistName = '', String albumTitle = ''}) => TrackRef(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
albumId: albumId,
|
||||||
|
albumTitle: albumTitle,
|
||||||
|
artistId: artistId,
|
||||||
|
artistName: artistName,
|
||||||
|
trackNumber: trackNumber,
|
||||||
|
discNumber: discNumber,
|
||||||
|
durationSec: durationMs ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TrackRefDriftWrite on TrackRef {
|
||||||
|
CachedTracksCompanion toDrift() => CachedTracksCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
albumId: albumId,
|
||||||
|
artistId: artistId,
|
||||||
|
title: title,
|
||||||
|
durationMs: drift.Value(durationSec * 1000),
|
||||||
|
trackNumber: drift.Value(trackNumber),
|
||||||
|
discNumber: drift.Value(discNumber),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CachedPlaylistAdapter on CachedPlaylist {
|
||||||
|
/// `ownerUsername` is server-derived; cache stores the userId only.
|
||||||
|
/// Pass empty unless a join supplies it.
|
||||||
|
/// `coverUrl` is reconstructed deterministically from the playlist
|
||||||
|
/// id — the server serves the cached collage at this path
|
||||||
|
/// (handleGetPlaylistCover). Mirrors the album-cover trick. When
|
||||||
|
/// the server hasn't built a collage yet (system playlists with no
|
||||||
|
/// tracks at build time), the endpoint 404s and PlaylistCard's
|
||||||
|
/// ServerImage falls back to its slate placeholder.
|
||||||
|
Playlist toRef({String ownerUsername = ''}) => Playlist(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
isPublic: isPublic,
|
||||||
|
systemVariant: systemVariant,
|
||||||
|
trackCount: trackCount,
|
||||||
|
coverUrl: '/api/playlists/$id/cover',
|
||||||
|
ownerUsername: ownerUsername,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaylistDriftWrite on Playlist {
|
||||||
|
CachedPlaylistsCompanion toDrift() => CachedPlaylistsCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
description: drift.Value(description),
|
||||||
|
isPublic: drift.Value(isPublic),
|
||||||
|
trackCount: drift.Value(trackCount),
|
||||||
|
systemVariant: drift.Value(systemVariant),
|
||||||
|
);
|
||||||
|
}
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// Per-bucket cache usage (#427 S2). Rolling includes orphan files —
|
||||||
|
/// partials written by LockCachingAudioSource that were never indexed
|
||||||
|
/// (skip-before-fully-buffered) — so the rolling cap actually bounds
|
||||||
|
/// disk.
|
||||||
|
typedef BucketUsage = ({int liked, int rolling});
|
||||||
|
|
||||||
|
/// Owns the audio cache directory + drift index.
|
||||||
|
///
|
||||||
|
/// #427 S2: two storage buckets keyed by liked-ness, not by
|
||||||
|
/// CacheSource. A cached track currently in the user's liked set is
|
||||||
|
/// charged to (and evicted under) the Liked budget; everything else
|
||||||
|
/// is Rolling. This dedup is storage/eviction-only — it never filters
|
||||||
|
/// what the offline surfaces can play (S4). `CacheSource` is retained
|
||||||
|
/// on the API for callers but is now informational.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
/// lastPlayedAt is set now — pinning is play-intent.
|
||||||
|
Future<String?> pin(String trackId, {required CacheSource source}) async {
|
||||||
|
final existing = await pathFor(trackId);
|
||||||
|
if (existing != null) {
|
||||||
|
await touch(trackId);
|
||||||
|
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,
|
||||||
|
lastPlayedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a file LockCachingAudioSource wrote itself. Called
|
||||||
|
/// once a track is fully buffered; it's being played, so stamp
|
||||||
|
/// lastPlayedAt.
|
||||||
|
Future<void> registerStreamCache(
|
||||||
|
String trackId,
|
||||||
|
String path,
|
||||||
|
int sizeBytes, {
|
||||||
|
CacheSource source = CacheSource.incidental,
|
||||||
|
}) async {
|
||||||
|
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||||
|
AudioCacheIndexCompanion.insert(
|
||||||
|
trackId: trackId,
|
||||||
|
path: path,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
source: source,
|
||||||
|
lastPlayedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bumps lastPlayedAt for an already-indexed track so the rolling
|
||||||
|
/// LRU + the offline "Recently played" view reflect real plays
|
||||||
|
/// (not just download time). No-op if not indexed.
|
||||||
|
Future<void> touch(String trackId) async {
|
||||||
|
await (_db.update(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.equals(trackId)))
|
||||||
|
.write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 on disk (directory walk — authoritative; catches
|
||||||
|
/// orphan partials the index misses).
|
||||||
|
Future<int> usageBytes() async {
|
||||||
|
final dir = Directory(await _tracksDir());
|
||||||
|
if (!await dir.exists()) return 0;
|
||||||
|
var total = 0;
|
||||||
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
|
if (e is File) {
|
||||||
|
try {
|
||||||
|
total += await e.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes per bucket. Indexed rows split by liked-ness; on-disk
|
||||||
|
/// files with no index row (orphan partials) count as Rolling so
|
||||||
|
/// the rolling cap genuinely bounds disk.
|
||||||
|
Future<BucketUsage> bucketUsage(Set<String> liked) async {
|
||||||
|
final rows = await _db.select(_db.audioCacheIndex).get();
|
||||||
|
final indexed = <String>{};
|
||||||
|
var likedB = 0;
|
||||||
|
var rollingB = 0;
|
||||||
|
for (final r in rows) {
|
||||||
|
indexed.add(r.trackId);
|
||||||
|
if (liked.contains(r.trackId)) {
|
||||||
|
likedB += r.sizeBytes;
|
||||||
|
} else {
|
||||||
|
rollingB += r.sizeBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final dir = Directory(await _tracksDir());
|
||||||
|
if (await dir.exists()) {
|
||||||
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
|
if (e is! File) continue;
|
||||||
|
final name = e.uri.pathSegments.last;
|
||||||
|
final id = name.endsWith('.mp3')
|
||||||
|
? name.substring(0, name.length - 4)
|
||||||
|
: name;
|
||||||
|
if (indexed.contains(id)) continue;
|
||||||
|
try {
|
||||||
|
rollingB += await e.length();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (liked: likedB, rolling: rollingB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces both budgets independently (0 = unlimited).
|
||||||
|
///
|
||||||
|
/// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt
|
||||||
|
/// /cachedAt first), then sweep orphan files oldest-by-mtime, until
|
||||||
|
/// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU
|
||||||
|
/// until ≤ likedCap. Liked is only ever touched by its own (large)
|
||||||
|
/// cap, so normal use never evicts the user's liked library.
|
||||||
|
Future<void> evictBuckets({
|
||||||
|
required int likedCap,
|
||||||
|
required int rollingCap,
|
||||||
|
required Set<String> liked,
|
||||||
|
}) async {
|
||||||
|
final usage = await bucketUsage(liked);
|
||||||
|
|
||||||
|
if (rollingCap > 0 && usage.rolling > rollingCap) {
|
||||||
|
var over = usage.rolling - rollingCap;
|
||||||
|
final rolling = await (_db.select(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.isNotIn(liked.toList()))
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||||
|
(t) => OrderingTerm.asc(t.cachedAt),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
for (final r in rolling) {
|
||||||
|
if (over <= 0) break;
|
||||||
|
final f = File(r.path);
|
||||||
|
if (f.existsSync()) await f.delete();
|
||||||
|
await (_db.delete(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.equals(r.trackId)))
|
||||||
|
.go();
|
||||||
|
over -= r.sizeBytes;
|
||||||
|
}
|
||||||
|
if (over > 0) await _sweepOrphans(over);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (likedCap > 0 && usage.liked > likedCap) {
|
||||||
|
var over = usage.liked - likedCap;
|
||||||
|
final likedRows = await (_db.select(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.isIn(liked.toList()))
|
||||||
|
..orderBy([
|
||||||
|
(t) => OrderingTerm.asc(t.lastPlayedAt),
|
||||||
|
(t) => OrderingTerm.asc(t.cachedAt),
|
||||||
|
]))
|
||||||
|
.get();
|
||||||
|
for (final r in likedRows) {
|
||||||
|
if (over <= 0) break;
|
||||||
|
final f = File(r.path);
|
||||||
|
if (f.existsSync()) await f.delete();
|
||||||
|
await (_db.delete(_db.audioCacheIndex)
|
||||||
|
..where((t) => t.trackId.equals(r.trackId)))
|
||||||
|
.go();
|
||||||
|
over -= r.sizeBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes orphan files (on disk, no index row) oldest-mtime-first
|
||||||
|
/// until `over` bytes are reclaimed. These are unindexed partials,
|
||||||
|
/// always Rolling, always evict-first.
|
||||||
|
Future<void> _sweepOrphans(int over) async {
|
||||||
|
final indexed = {
|
||||||
|
for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId
|
||||||
|
};
|
||||||
|
final dir = Directory(await _tracksDir());
|
||||||
|
if (!await dir.exists()) return;
|
||||||
|
final orphans = <({File f, int size, DateTime mtime})>[];
|
||||||
|
await for (final e in dir.list(followLinks: false)) {
|
||||||
|
if (e is! File) continue;
|
||||||
|
final name = e.uri.pathSegments.last;
|
||||||
|
final id =
|
||||||
|
name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name;
|
||||||
|
if (indexed.contains(id)) continue;
|
||||||
|
try {
|
||||||
|
final st = e.statSync();
|
||||||
|
orphans.add((f: e, size: st.size, mtime: st.modified));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
orphans.sort((a, b) => a.mtime.compareTo(b.mtime));
|
||||||
|
var remaining = over;
|
||||||
|
for (final o in orphans) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
try {
|
||||||
|
await o.f.delete();
|
||||||
|
remaining -= o.size;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears EVERY row + EVERY file. Wired to "Clear cache".
|
||||||
|
Future<void> clearAll() async {
|
||||||
|
final dir = await _tracksDir();
|
||||||
|
final d = Directory(dir);
|
||||||
|
if (d.existsSync()) {
|
||||||
|
for (final e in d.listSync()) {
|
||||||
|
if (e is File) await e.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),
|
||||||
|
);
|
||||||
|
});
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
// Background metadata sweeper that walks the drift cache for missing
|
||||||
|
// relations and fills them via /api/artists/:id + /api/albums/:id.
|
||||||
|
// Cover bytes for newly-filled albums are pre-warmed too so the
|
||||||
|
// per-tile display path doesn't have to wait on network.
|
||||||
|
//
|
||||||
|
// Why this layer on top of SyncController and HydrationQueue:
|
||||||
|
// - SyncController.sync only ingests entities the server emitted
|
||||||
|
// into the library_changes delta for this user. For a fresh
|
||||||
|
// install, that's everything; for an existing user, only recent
|
||||||
|
// changes. The per-artist album list and per-album track list
|
||||||
|
// are NOT in those deltas — they're derived at query time.
|
||||||
|
// - HydrationQueue fills these lazily on tile render. CacheFiller
|
||||||
|
// fills them proactively on a slow schedule so tapping an artist
|
||||||
|
// surfaces albums immediately instead of triggering a fresh
|
||||||
|
// /api/artists/:id at tap time.
|
||||||
|
//
|
||||||
|
// Pacing is conservative — 200ms between requests, max 200 entities
|
||||||
|
// per sweep, 5-minute interval. Designed to never compete with user
|
||||||
|
// activity. Wall time on a 1000-artist library: ~3-4 minutes spread
|
||||||
|
// over multiple sweeps.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show Variable;
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart'
|
||||||
|
show serverUrlProvider, sessionTokenProvider;
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
|
||||||
|
class CacheFiller {
|
||||||
|
CacheFiller(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Delay between launch and the first sweep. Long enough for
|
||||||
|
/// SyncController to land its initial /api/library/sync so the
|
||||||
|
/// CacheFiller's "unfilled relations" query has meaningful work.
|
||||||
|
static const _initialDelay = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Cadence between sweeps. Once a sweep finds nothing to do (steady
|
||||||
|
/// state) the interval becomes a cheap drift query + early exit.
|
||||||
|
static const _interval = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Throttle between per-entity REST requests so the filler doesn't
|
||||||
|
/// saturate the server or compete with user-initiated playback.
|
||||||
|
static const _requestThrottle = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
/// Per-sweep cap. Without this, a fresh install with thousands of
|
||||||
|
/// artists would tie up the network for many minutes in one go.
|
||||||
|
/// The next sweep continues where this one left off (the WHERE
|
||||||
|
/// NOT EXISTS query naturally skips already-filled rows).
|
||||||
|
static const _maxIdsPerSweep = 200;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
_initialTimer = Timer(_initialDelay, _sweep);
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => _sweep());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sweep() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
await _fillArtists();
|
||||||
|
if (_disposed) return;
|
||||||
|
await _fillAlbums();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('cache_filler: sweep failed: $e\n$st');
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find artists with no albums in cache and fetch /api/artists/:id
|
||||||
|
/// for each (single round-trip yields artist + albums via the new
|
||||||
|
/// getArtistDetail API method). Newly-discovered album covers
|
||||||
|
/// land in flutter_cache_manager's disk cache too so the next
|
||||||
|
/// visit to the artist's albums grid paints from disk.
|
||||||
|
Future<void> _fillArtists() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_artists a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final newAlbumIds = <String>[];
|
||||||
|
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final detail = await api.getArtistDetail(id);
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db
|
||||||
|
.into(db.cachedArtists)
|
||||||
|
.insertOnConflictUpdate(detail.artist.toDrift());
|
||||||
|
if (detail.albums.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
detail.albums.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
newAlbumIds.addAll(detail.albums.map((a) => a.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillArtist($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAlbumIds.isNotEmpty) {
|
||||||
|
await _prewarmCovers(newAlbumIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find albums with no tracks in cache and fetch /api/albums/:id
|
||||||
|
/// for each. The bulk response carries the album's track list which
|
||||||
|
/// we persist into cached_tracks for instant album detail render
|
||||||
|
/// on the next visit.
|
||||||
|
Future<void> _fillAlbums() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'''
|
||||||
|
SELECT a.id FROM cached_albums a
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id
|
||||||
|
)
|
||||||
|
LIMIT ?
|
||||||
|
''',
|
||||||
|
variables: [Variable.withInt(_maxIdsPerSweep)],
|
||||||
|
).get();
|
||||||
|
final ids = rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
if (ids.isEmpty) return;
|
||||||
|
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
for (final id in ids) {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final result = await api.getAlbum(id);
|
||||||
|
if (result.tracks.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks,
|
||||||
|
result.tracks.map((t) => t.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('cache_filler: fillAlbum($id) failed: $e');
|
||||||
|
}
|
||||||
|
await Future.delayed(_requestThrottle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-warm album cover bytes via flutter_cache_manager so the
|
||||||
|
/// per-tile ServerImage / CachedNetworkImage paints from disk on
|
||||||
|
/// the user's first visit. Mirrors SyncController's prewarm
|
||||||
|
/// helper — duplicated here intentionally to keep this file self-
|
||||||
|
/// contained; a shared helper can come out of #357 follow-up if
|
||||||
|
/// the duplication grows.
|
||||||
|
Future<void> _prewarmCovers(List<String> albumIds) async {
|
||||||
|
final baseUrl = await _ref.read(serverUrlProvider.future);
|
||||||
|
if (baseUrl == null || baseUrl.isEmpty) return;
|
||||||
|
final token = await _ref.read(sessionTokenProvider.future);
|
||||||
|
final headers = (token != null && token.isNotEmpty)
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: <String, String>{};
|
||||||
|
final base = baseUrl.endsWith('/')
|
||||||
|
? baseUrl.substring(0, baseUrl.length - 1)
|
||||||
|
: baseUrl;
|
||||||
|
final mgr = DefaultCacheManager();
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (idx < albumIds.length) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final i = idx++;
|
||||||
|
try {
|
||||||
|
await mgr.downloadFile(
|
||||||
|
'$base/api/albums/${albumIds[i]}/cover',
|
||||||
|
authHeaders: headers,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort prewarm — 404 (missing collage) and 401
|
||||||
|
// (token refresh races) shouldn't abort the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrency 3 — same as SyncController's prewarm. Covers are
|
||||||
|
// small enough that more parallelism doesn't help much and
|
||||||
|
// burns radio.
|
||||||
|
await Future.wait(List.generate(3, (_) => worker()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start (from app.dart's postFrameCallback) to
|
||||||
|
/// activate the filler. Disposed via ref.onDispose when the provider
|
||||||
|
/// scope tears down; in practice the scope lives for the app's
|
||||||
|
/// lifetime so dispose only fires on uninstall / process death.
|
||||||
|
final cacheFillerProvider = Provider<CacheFiller>((ref) {
|
||||||
|
final filler = CacheFiller(ref);
|
||||||
|
ref.onDispose(filler.dispose);
|
||||||
|
filler.start();
|
||||||
|
return filler;
|
||||||
|
});
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// Drift-first reactive read pattern with REST cold-cache fallback (#357 plan C).
|
||||||
|
//
|
||||||
|
// Subscribes to a drift watch() stream. On each emission:
|
||||||
|
// - non-empty → map to result type T and yield
|
||||||
|
// - empty + online → fetch via REST, populate drift, await re-emission
|
||||||
|
// - empty + offline → yield mapped empty result (UI shows empty state)
|
||||||
|
//
|
||||||
|
// With `alwaysRefresh: true`, also kicks off a one-shot REST refresh
|
||||||
|
// in the background after the first non-empty emission. Use for
|
||||||
|
// aggregate lists (playlists, etc.) where the server may have rows
|
||||||
|
// the local sync didn't pick up — yields cache immediately, refreshes
|
||||||
|
// silently, and drift watch() re-emits with whatever new rows landed.
|
||||||
|
//
|
||||||
|
// The pattern lets every read provider trust drift as the source of
|
||||||
|
// truth. SyncController keeps drift fresh in the background; widget
|
||||||
|
// rebuilds happen automatically as drift writes propagate via watch().
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
|
||||||
|
// `tag` parameter is preserved for future ad-hoc instrumentation.
|
||||||
|
// Normal operation only logs failure paths so the per-screen log
|
||||||
|
// noise stays low.
|
||||||
|
|
||||||
|
/// Wraps the watch + cold-cache fallback pattern. Generic over:
|
||||||
|
/// D — the drift row type (or TypedResult for joins)
|
||||||
|
/// T — the result type the caller wants (e.g. `List<ArtistRef>`)
|
||||||
|
///
|
||||||
|
/// `fetchAndPopulate` is invoked when drift is empty AND `isOnline()`
|
||||||
|
/// returns true. It must populate drift via its own side-effect; the
|
||||||
|
/// drift watch() stream will re-emit and this helper yields the
|
||||||
|
/// populated rows on the next iteration.
|
||||||
|
///
|
||||||
|
/// REST failures are swallowed — the helper falls through to yielding
|
||||||
|
/// the empty result. Caller is responsible for surfacing errors via
|
||||||
|
/// toast etc.
|
||||||
|
Stream<T> cacheFirst<D, T>({
|
||||||
|
required Stream<List<D>> driftStream,
|
||||||
|
required Future<void> Function() fetchAndPopulate,
|
||||||
|
required T Function(List<D>) toResult,
|
||||||
|
required Future<bool> Function() isOnline,
|
||||||
|
bool alwaysRefresh = false,
|
||||||
|
String? tag,
|
||||||
|
}) async* {
|
||||||
|
// Tracks whether we've already kicked off a stale-while-revalidate
|
||||||
|
// refresh for this stream subscription, so we don't fire one on every
|
||||||
|
// drift re-emission (otherwise the populate cycles forever).
|
||||||
|
var revalidated = false;
|
||||||
|
|
||||||
|
// Tracks whether we've already tried a cold-cache fetch on this
|
||||||
|
// subscription. Without this, providers can spin forever in the
|
||||||
|
// rows-empty branch when fetchAndPopulate writes rows that don't
|
||||||
|
// match THIS filter — e.g. three liked-tab streams sharing one
|
||||||
|
// populate: the track populate writes track-typed rows, drift
|
||||||
|
// watch fires for the cached_likes table, the album stream
|
||||||
|
// re-emits with rows-empty (no album likes), the rows-empty
|
||||||
|
// branch re-fires populate, repeats forever. Setting this guard
|
||||||
|
// makes the first fetch attempt also the last for any given
|
||||||
|
// subscription.
|
||||||
|
var coldFetchAttempted = false;
|
||||||
|
|
||||||
|
await for (final rows in driftStream) {
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
yield toResult(rows);
|
||||||
|
coldFetchAttempted = true;
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// rows is empty. If we've already attempted a cold fetch and
|
||||||
|
// drift is still empty for this filter, yield empty so the UI
|
||||||
|
// shows the no-content state instead of spinning forever.
|
||||||
|
if (coldFetchAttempted) {
|
||||||
|
yield toResult(rows);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
coldFetchAttempted = true;
|
||||||
|
if (await isOnline()) {
|
||||||
|
try {
|
||||||
|
await fetchAndPopulate();
|
||||||
|
// Yield the current (still-empty) rows so the UI moves past
|
||||||
|
// loading even if populate was a no-op for this filter
|
||||||
|
// (server returned nothing matching, or all rows were
|
||||||
|
// already in drift via sync). If populate DID write rows
|
||||||
|
// matching this filter, the drift watch's next emission
|
||||||
|
// yields them via the rows.isNotEmpty branch — UI briefly
|
||||||
|
// shows empty then populates, instead of spinning.
|
||||||
|
yield toResult(rows);
|
||||||
|
} catch (e, st) {
|
||||||
|
if (tag != null) {
|
||||||
|
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||||
|
}
|
||||||
|
yield toResult(rows); // empty result; caller surfaces error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
yield toResult(rows); // empty result; offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _safeFetch(Future<void> Function() fn) async {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (_) {
|
||||||
|
// Background revalidate — swallow; UI already showed cached state.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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. Persisted via
|
||||||
|
/// flutter_secure_storage on the same device.
|
||||||
|
///
|
||||||
|
/// #427 S2: the single `capBytes` is replaced by two independent
|
||||||
|
/// budgets — Liked and Rolling-recent — each defaulting to 5GB.
|
||||||
|
/// Bucketing is by liked-ness (a cached track currently in the
|
||||||
|
/// user's liked set is charged to Liked; everything else to
|
||||||
|
/// Rolling), so the dedup is storage-only and never filters
|
||||||
|
/// playback. The old `cache_cap_bytes` key is intentionally not
|
||||||
|
/// migrated; defaults reapply (a one-time re-tune, not data loss).
|
||||||
|
class CacheSettings {
|
||||||
|
const CacheSettings({
|
||||||
|
required this.likedCapBytes,
|
||||||
|
required this.rollingCapBytes,
|
||||||
|
required this.prefetchWindow,
|
||||||
|
required this.cacheLikedTracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Liked-bucket budget. 0 = unlimited.
|
||||||
|
final int likedCapBytes;
|
||||||
|
|
||||||
|
/// Rolling (recently-played) budget. 0 = unlimited.
|
||||||
|
final int rollingCapBytes;
|
||||||
|
|
||||||
|
/// 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? likedCapBytes,
|
||||||
|
int? rollingCapBytes,
|
||||||
|
int? prefetchWindow,
|
||||||
|
bool? cacheLikedTracks,
|
||||||
|
}) =>
|
||||||
|
CacheSettings(
|
||||||
|
likedCapBytes: likedCapBytes ?? this.likedCapBytes,
|
||||||
|
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
|
||||||
|
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
|
||||||
|
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
static const defaults = CacheSettings(
|
||||||
|
likedCapBytes: _fiveGiB,
|
||||||
|
rollingCapBytes: _fiveGiB,
|
||||||
|
prefetchWindow: 5,
|
||||||
|
cacheLikedTracks: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
|
||||||
|
static const _kLikedCap = 'cache_liked_cap_bytes';
|
||||||
|
static const _kRollingCap = 'cache_rolling_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 likedCap = await _storage.read(key: _kLikedCap);
|
||||||
|
final rollingCap = await _storage.read(key: _kRollingCap);
|
||||||
|
final pre = await _storage.read(key: _kPrefetch);
|
||||||
|
final liked = await _storage.read(key: _kCacheLiked);
|
||||||
|
return CacheSettings(
|
||||||
|
likedCapBytes: likedCap == null
|
||||||
|
? CacheSettings.defaults.likedCapBytes
|
||||||
|
: int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
|
||||||
|
rollingCapBytes: rollingCap == null
|
||||||
|
? CacheSettings.defaults.rollingCapBytes
|
||||||
|
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
|
||||||
|
prefetchWindow: pre == null
|
||||||
|
? CacheSettings.defaults.prefetchWindow
|
||||||
|
: (int.tryParse(pre) ?? 5).clamp(1, 10),
|
||||||
|
cacheLikedTracks: liked == null
|
||||||
|
? CacheSettings.defaults.cacheLikedTracks
|
||||||
|
: liked == 'true',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLikedCapBytes(int bytes) async {
|
||||||
|
await _storage.write(key: _kLikedCap, value: bytes.toString());
|
||||||
|
state = AsyncData(state.value!.copyWith(likedCapBytes: bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRollingCapBytes(int bytes) async {
|
||||||
|
await _storage.write(key: _kRollingCap, value: bytes.toString());
|
||||||
|
state = AsyncData(state.value!.copyWith(rollingCapBytes: 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
+343
@@ -0,0 +1,343 @@
|
|||||||
|
// 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)();
|
||||||
|
/// When the track was last played. Drives the offline "Recently
|
||||||
|
/// played" ordering and rolling-bucket LRU eviction. Distinct from
|
||||||
|
/// cachedAt (download time). Nullable for pre-schema-9 rows until
|
||||||
|
/// the next play touches them (migration backfills to cachedAt).
|
||||||
|
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
|
||||||
|
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};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-row cache of the /api/me/history response. Mirrors the
|
||||||
|
/// CachedHomeSnapshot pattern: opening the History tab in the Library
|
||||||
|
/// screen yields the last-known page immediately while a fresh REST
|
||||||
|
/// pull lands underneath via SWR. Also makes basic offline scrollback
|
||||||
|
/// possible — the last fetched page survives both app restart and
|
||||||
|
/// loss of connectivity. Schema 4+.
|
||||||
|
class CachedHistorySnapshot extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-row cache of the /api/me/system-playlists-status response.
|
||||||
|
/// The home Playlists row reads this every render to pick between
|
||||||
|
/// real cards and placeholder cards for For-You / Discover / Songs-
|
||||||
|
/// like slots, so a fresh-mount cold fetch produces a visible
|
||||||
|
/// "building / pending / failed" flicker. Storing the last result
|
||||||
|
/// as a JSON blob means the home renders with the prior status
|
||||||
|
/// instantly, then SWR refreshes underneath. Schema 7+.
|
||||||
|
class CachedSystemPlaylistsStatus extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section→position→entity-id index for the home screen, populated
|
||||||
|
/// from the per-item discovery endpoint `/api/home/index`. Each row
|
||||||
|
/// pins one tile slot to an entity; the actual entity data lives in
|
||||||
|
/// cached_albums / cached_artists / cached_tracks. The home screen
|
||||||
|
/// reads this table to know the layout, then hydrates each tile
|
||||||
|
/// against the entity tables (per-item rendering). Schema 6+.
|
||||||
|
class CachedHomeIndex extends Table {
|
||||||
|
/// One of: 'recently_added_albums', 'rediscover_albums',
|
||||||
|
/// 'rediscover_artists', 'most_played_tracks', 'last_played_artists'.
|
||||||
|
/// Mirrors the keys /api/home and /api/home/index emit.
|
||||||
|
TextColumn get section => text()();
|
||||||
|
IntColumn get position => integer()();
|
||||||
|
/// One of: 'album', 'artist', 'track'. Used to dispatch hydration
|
||||||
|
/// to the right per-entity endpoint.
|
||||||
|
TextColumn get entityType => text()();
|
||||||
|
TextColumn get entityId => text()();
|
||||||
|
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {section, position};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outbound mutation queue for offline-resilient REST calls. Likes,
|
||||||
|
/// quarantine flag/unflag, playlist appendTracks, Lidarr request
|
||||||
|
/// create/cancel all enqueue here on REST failure so the user's
|
||||||
|
/// intent persists across network loss. MutationReplayer drains on
|
||||||
|
/// connectivity transitions and a 1-minute periodic tick; entries
|
||||||
|
/// with attempts >= 5 are skipped (the server treats them as
|
||||||
|
/// permanent failures and library_changes sync will correct any
|
||||||
|
/// resulting drift drift on next sync). Schema 8+.
|
||||||
|
class CachedMutations extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
/// Stable kind string (see mutation_queue.dart constants). The
|
||||||
|
/// replayer looks up the handler in a kind→Function map; unknown
|
||||||
|
/// kinds are dropped to avoid getting stuck.
|
||||||
|
TextColumn get kind => text()();
|
||||||
|
/// JSON-encoded payload — args needed to replay the REST call.
|
||||||
|
/// Shape depends on kind; see mutation_queue.dart.
|
||||||
|
TextColumn get payload => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
/// When the last drain attempt fired against this row. Null until
|
||||||
|
/// the first attempt. Useful for diagnostic queries.
|
||||||
|
DateTimeColumn get lastAttemptAt => dateTime().nullable()();
|
||||||
|
IntColumn get attempts => integer().withDefault(const Constant(0))();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-scoped quarantine ("hidden") rows. Mirrors the wire shape of
|
||||||
|
/// /api/quarantine/mine — fully denormalized (track + album + artist
|
||||||
|
/// fields inline) because the Hidden tab renders straight from this row
|
||||||
|
/// without joining other tables. Columnar (vs JSON blob) so flag/unflag
|
||||||
|
/// can do row-level INSERT/DELETE; the drift watch() emission feeds
|
||||||
|
/// MyQuarantineController state directly. Schema 5+.
|
||||||
|
class CachedQuarantineMine extends Table {
|
||||||
|
TextColumn get trackId => text()();
|
||||||
|
TextColumn get reason => text()();
|
||||||
|
TextColumn get notes => text().nullable()();
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
TextColumn get trackTitle => text()();
|
||||||
|
IntColumn get trackDurationMs => integer().withDefault(const Constant(0))();
|
||||||
|
TextColumn get albumId => text()();
|
||||||
|
TextColumn get albumTitle => text()();
|
||||||
|
TextColumn get albumCoverArtPath => text().nullable()();
|
||||||
|
TextColumn get artistId => text()();
|
||||||
|
TextColumn get artistName => text()();
|
||||||
|
DateTimeColumn get fetchedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {trackId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-row snapshot of the last playback session — queue (TrackRef
|
||||||
|
/// JSON), current index, position, and #415 source. Lets a torn-down
|
||||||
|
/// session (the #52 idle/dismissed teardown) resume on next launch;
|
||||||
|
/// without it the headset / lock-screen play button has nothing to
|
||||||
|
/// resume. Mirrors the CachedHomeSnapshot single-row JSON pattern.
|
||||||
|
/// Schema 10+.
|
||||||
|
class CachedResumeState extends Table {
|
||||||
|
IntColumn get id => integer().withDefault(const Constant(1))();
|
||||||
|
TextColumn get json => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [
|
||||||
|
CachedArtists,
|
||||||
|
CachedAlbums,
|
||||||
|
CachedTracks,
|
||||||
|
CachedLikes,
|
||||||
|
CachedPlaylists,
|
||||||
|
CachedPlaylistTracks,
|
||||||
|
AudioCacheIndex,
|
||||||
|
SyncMetadata,
|
||||||
|
CachedHistorySnapshot,
|
||||||
|
CachedQuarantineMine,
|
||||||
|
CachedHomeIndex,
|
||||||
|
CachedSystemPlaylistsStatus,
|
||||||
|
CachedMutations,
|
||||||
|
CachedResumeState,
|
||||||
|
])
|
||||||
|
class AppDb extends _$AppDb {
|
||||||
|
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 11;
|
||||||
|
|
||||||
|
@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');
|
||||||
|
}
|
||||||
|
if (from < 3) {
|
||||||
|
// Schema 3: cached_home_snapshot (legacy drift-first home).
|
||||||
|
// The table + its homeProvider/JSON encoders were removed
|
||||||
|
// in #406; recreated here as raw SQL so this historical
|
||||||
|
// step still compiles without the generated table symbol.
|
||||||
|
// The from<11 step below drops it.
|
||||||
|
await customStatement(
|
||||||
|
'CREATE TABLE IF NOT EXISTS "cached_home_snapshot" '
|
||||||
|
'("id" INTEGER NOT NULL DEFAULT 1, "json" TEXT, '
|
||||||
|
'"updated_at" INTEGER, PRIMARY KEY ("id"));',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 4) {
|
||||||
|
// Schema 4: cached_history_snapshot for drift-first
|
||||||
|
// History tab. Same pattern as cached_home_snapshot —
|
||||||
|
// empty on upgrade; first /api/me/history fetch populates.
|
||||||
|
await m.createTable(cachedHistorySnapshot);
|
||||||
|
}
|
||||||
|
if (from < 5) {
|
||||||
|
// Schema 5: cached_quarantine_mine for drift-first Hidden
|
||||||
|
// tab. Empty on upgrade; first /api/quarantine/mine fetch
|
||||||
|
// populates. Optimistic flag/unflag also writes here.
|
||||||
|
await m.createTable(cachedQuarantineMine);
|
||||||
|
}
|
||||||
|
if (from < 6) {
|
||||||
|
// Schema 6: cached_home_index for per-item rendering.
|
||||||
|
// Empty on upgrade; first /api/home/index fetch populates.
|
||||||
|
// cached_home_snapshot stays in place for now — older
|
||||||
|
// builds still read from it as a fallback path.
|
||||||
|
await m.createTable(cachedHomeIndex);
|
||||||
|
}
|
||||||
|
if (from < 7) {
|
||||||
|
// Schema 7: cached_system_playlists_status. Single-row
|
||||||
|
// snapshot of /api/me/system-playlists-status used by the
|
||||||
|
// home Playlists row. Empty on upgrade; first fetch
|
||||||
|
// populates.
|
||||||
|
await m.createTable(cachedSystemPlaylistsStatus);
|
||||||
|
}
|
||||||
|
if (from < 8) {
|
||||||
|
// Schema 8: cached_mutations outbound queue. Empty on
|
||||||
|
// upgrade; populated by controllers when REST calls fail.
|
||||||
|
await m.createTable(cachedMutations);
|
||||||
|
}
|
||||||
|
if (from < 9) {
|
||||||
|
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
|
||||||
|
// gives the offline "Recently played" view a real
|
||||||
|
// recency signal (cachedAt is download time, not play
|
||||||
|
// time). Nullable — backfilled to cachedAt so existing
|
||||||
|
// rows order sensibly until next play touches them.
|
||||||
|
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
|
||||||
|
await customStatement(
|
||||||
|
'UPDATE audio_cache_index SET last_played_at = cached_at',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 10) {
|
||||||
|
// Schema 10 (#54): cached_resume_state — single-row last-
|
||||||
|
// session snapshot for resume-on-launch. Empty on upgrade;
|
||||||
|
// first persist populates it.
|
||||||
|
await m.createTable(cachedResumeState);
|
||||||
|
}
|
||||||
|
if (from < 11) {
|
||||||
|
// Schema 11 (#406): drop the legacy cached_home_snapshot
|
||||||
|
// table. The home screen now renders solely from the
|
||||||
|
// per-item cached_home_index path; the legacy homeProvider
|
||||||
|
// + its JSON encoders were removed.
|
||||||
|
await customStatement(
|
||||||
|
'DROP TABLE IF EXISTS "cached_home_snapshot";',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
// Per-item hydration coordinator for the home/playlist/liked surfaces.
|
||||||
|
//
|
||||||
|
// Tile widgets are reactive to their per-entity drift row. On first
|
||||||
|
// build, if drift has no row for the requested id, the tile asks this
|
||||||
|
// queue to hydrate it. The queue dispatches one /api/<type>/:id
|
||||||
|
// request, writes the result to drift, and the drift watch() on the
|
||||||
|
// tile re-emits with the populated row.
|
||||||
|
//
|
||||||
|
// Concurrency cap: HydrationQueue limits the number of in-flight
|
||||||
|
// requests so a 50-tile home screen doesn't fire 50 parallel /api/
|
||||||
|
// calls and stall the auth-token-bound dio pool. In-flight dedup
|
||||||
|
// (keyed by `<type>:<id>`) ensures a single hydration when multiple
|
||||||
|
// tiles share an entity (rare on home, common when several screens
|
||||||
|
// reference the same album).
|
||||||
|
//
|
||||||
|
// Failures are swallowed — a single failed hydration leaves the tile
|
||||||
|
// in skeleton state. A future retry-on-visit pass can layer on top
|
||||||
|
// without changing the queue's contract.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
|
||||||
|
/// Bounded-concurrency request queue for per-entity hydration. One
|
||||||
|
/// instance per Riverpod scope (provider singleton below).
|
||||||
|
class HydrationQueue {
|
||||||
|
HydrationQueue(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Concurrent slot count. 4 keeps the dio token pool from saturating
|
||||||
|
/// while still pulling tiles down faster than a serial loop. Bump
|
||||||
|
/// if profiling shows the queue idle while the network is healthy.
|
||||||
|
static const _maxConcurrent = 4;
|
||||||
|
|
||||||
|
final Queue<_HydrationRequest> _pending = Queue();
|
||||||
|
final Set<String> _inFlightKeys = {};
|
||||||
|
int _inFlight = 0;
|
||||||
|
|
||||||
|
/// Request hydration for (entityType, entityId). Idempotent — second
|
||||||
|
/// call for the same key while the first is queued or in flight is
|
||||||
|
/// dropped, so multiple tile rebuilds during a fast scroll don't
|
||||||
|
/// inflate the queue.
|
||||||
|
void enqueue({required String entityType, required String entityId}) {
|
||||||
|
if (entityId.isEmpty) return;
|
||||||
|
final key = '$entityType:$entityId';
|
||||||
|
if (_inFlightKeys.contains(key)) return;
|
||||||
|
if (_pending.any((r) => r.key == key)) return;
|
||||||
|
_pending.add(_HydrationRequest(
|
||||||
|
entityType: entityType, entityId: entityId, key: key));
|
||||||
|
_pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pump() {
|
||||||
|
while (_inFlight < _maxConcurrent && _pending.isNotEmpty) {
|
||||||
|
final req = _pending.removeFirst();
|
||||||
|
_inFlightKeys.add(req.key);
|
||||||
|
_inFlight++;
|
||||||
|
// Unawaited on purpose — _pump returns immediately so further
|
||||||
|
// enqueues can fill remaining slots while this one runs.
|
||||||
|
unawaited(_execute(req).whenComplete(() {
|
||||||
|
_inFlight--;
|
||||||
|
_inFlightKeys.remove(req.key);
|
||||||
|
_pump();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _execute(_HydrationRequest req) async {
|
||||||
|
try {
|
||||||
|
switch (req.entityType) {
|
||||||
|
case 'album':
|
||||||
|
await _hydrateAlbum(req.entityId);
|
||||||
|
case 'artist':
|
||||||
|
await _hydrateArtist(req.entityId);
|
||||||
|
case 'track':
|
||||||
|
await _hydrateTrack(req.entityId);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow — tile stays in skeleton. A retry-on-visit pass can
|
||||||
|
// layer on top later without changing this contract.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateAlbum(String id) async {
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final result = await api.getAlbum(id);
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
// Album + tracks come bundled in the same response; persist both
|
||||||
|
// so opening the album detail later is also a drift hit.
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db
|
||||||
|
.into(db.cachedAlbums)
|
||||||
|
.insertOnConflictUpdate(result.album.toDrift());
|
||||||
|
if (result.tracks.isNotEmpty) {
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateArtist(String id) async {
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getArtist(id);
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _hydrateTrack(String id) async {
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getTrack(id);
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedTracks).insertOnConflictUpdate(fresh.toDrift());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HydrationRequest {
|
||||||
|
_HydrationRequest({
|
||||||
|
required this.entityType,
|
||||||
|
required this.entityId,
|
||||||
|
required this.key,
|
||||||
|
});
|
||||||
|
final String entityType;
|
||||||
|
final String entityId;
|
||||||
|
final String key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton queue scoped to the Riverpod container. Kept as a plain
|
||||||
|
/// Provider (not StateProvider) since the queue's internal state is
|
||||||
|
/// pump-driven, not reactively observed.
|
||||||
|
final hydrationQueueProvider = Provider<HydrationQueue>((ref) {
|
||||||
|
return HydrationQueue(ref);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart';
|
||||||
|
import '../models/home_index.dart';
|
||||||
|
|
||||||
|
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||||
|
/// purpose: only warms artistProvider rows (single row, single round
|
||||||
|
/// trip per id) and only ever fires once per id per session. Album
|
||||||
|
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
|
||||||
|
/// list when missing, and pre-warming N albums fans out N parallel
|
||||||
|
/// "fetch tracks" round trips that compete with the user's actual
|
||||||
|
/// playback request for bandwidth.
|
||||||
|
///
|
||||||
|
/// Driven off the per-item home path (homeIndexProvider). Only the
|
||||||
|
/// artist-typed sections (rediscover / last-played artists) carry
|
||||||
|
/// artist ids directly; album/track tiles hydrate their own artist on
|
||||||
|
/// render via the per-item path, so warming those here is unnecessary.
|
||||||
|
class MetadataPrefetcher {
|
||||||
|
MetadataPrefetcher(this._ref) {
|
||||||
|
_ref.listen<AsyncValue<HomeIndex>>(homeIndexProvider, (_, next) {
|
||||||
|
next.whenData(_warmIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Per-session dedupe so re-rendering a screen (every UI rebuild
|
||||||
|
/// fires the data: callback) doesn't trigger N more fetches for
|
||||||
|
/// ids we've already warmed.
|
||||||
|
final Set<String> _warmedArtists = {};
|
||||||
|
|
||||||
|
/// Cap warmed per emission. Covers what fits on screen without
|
||||||
|
/// scrolling.
|
||||||
|
static const _topN = 8;
|
||||||
|
|
||||||
|
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||||
|
/// see class comment.
|
||||||
|
void warmArtists(Iterable<String> ids) {
|
||||||
|
var n = 0;
|
||||||
|
for (final id in ids) {
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
if (!_warmedArtists.add(id)) continue; // already warmed
|
||||||
|
if (n++ >= _topN) break;
|
||||||
|
_swallow(_ref.read(artistProvider(id).future));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _warmIndex(HomeIndex h) {
|
||||||
|
warmArtists(<String>{
|
||||||
|
...h.rediscoverArtists.take(_topN),
|
||||||
|
...h.lastPlayedArtists.take(_topN),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discards the return value and any error from a fire-and-forget
|
||||||
|
/// provider read. We don't care about the value here — we only want
|
||||||
|
/// the side effect of writing drift.
|
||||||
|
void _swallow(Future<Object?> f) {
|
||||||
|
f.then<void>((_) {}).onError((_, __) {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start to activate the prefetcher (e.g. wire it
|
||||||
|
/// from a top-level Consumer or main.dart container override).
|
||||||
|
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
|
||||||
|
return MetadataPrefetcher(ref);
|
||||||
|
});
|
||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
// Outbound mutation queue for offline-resilient REST calls.
|
||||||
|
//
|
||||||
|
// Controllers (LikesController, MyQuarantineController, the add-to-
|
||||||
|
// playlist sheet, the Discover request flow, the Requests cancel
|
||||||
|
// action) write their optimistic local state to drift first and try
|
||||||
|
// the corresponding REST call. On failure they enqueue here. The
|
||||||
|
// replayer drains the queue when connectivity comes back; the user's
|
||||||
|
// intent persists across network loss without rolling back their
|
||||||
|
// visible action.
|
||||||
|
//
|
||||||
|
// Why this is separate from SyncController: sync ingests AUTHORITATIVE
|
||||||
|
// SERVER state into drift. The mutation queue carries USER INTENT
|
||||||
|
// outbound to the server. They flow in opposite directions and
|
||||||
|
// shouldn't be conflated — sync wins on conflict (server is the
|
||||||
|
// source of truth), and a mutation that fails forever (5 attempts)
|
||||||
|
// is dropped on the trust that next sync will reconcile drift to
|
||||||
|
// match the server's actual state.
|
||||||
|
//
|
||||||
|
// Drop semantics: 5 attempts then drop. The drop is intentional —
|
||||||
|
// holding onto a forever-failing mutation just delays sync's
|
||||||
|
// reconciliation. The user's optimistic drift state will diverge
|
||||||
|
// from server, and the next library_changes delta corrects it.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart' show DioException, DioExceptionType;
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/discover.dart';
|
||||||
|
import '../api/endpoints/events.dart';
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likesApiProvider;
|
||||||
|
import '../models/lidarr.dart' show LidarrRequestKind;
|
||||||
|
import '../playlists/playlists_provider.dart' show playlistsApiProvider;
|
||||||
|
import '../quarantine/quarantine_provider.dart' show quarantineApiProvider;
|
||||||
|
import '../requests/requests_provider.dart' show requestsApiProvider;
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'connectivity_provider.dart';
|
||||||
|
import 'db.dart';
|
||||||
|
|
||||||
|
/// Stable kind constants. Each controller emits one of these via
|
||||||
|
/// MutationQueue.enqueue; the replayer dispatches by kind.
|
||||||
|
class MutationKinds {
|
||||||
|
MutationKinds._();
|
||||||
|
static const likeAdd = 'like.add';
|
||||||
|
static const likeRemove = 'like.remove';
|
||||||
|
static const quarantineFlag = 'quarantine.flag';
|
||||||
|
static const quarantineUnflag = 'quarantine.unflag';
|
||||||
|
static const playlistAppend = 'playlist.append';
|
||||||
|
static const requestCreate = 'request.create';
|
||||||
|
static const requestCancel = 'request.cancel';
|
||||||
|
static const playOffline = 'play.offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
class MutationQueue {
|
||||||
|
MutationQueue(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Persist a mutation for replay. Caller has already done the
|
||||||
|
/// optimistic local mutation (drift write, in-memory state update)
|
||||||
|
/// — this only records the REST call that needs to fire eventually.
|
||||||
|
Future<void> enqueue(String kind, Map<String, dynamic> payload) async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedMutations).insert(
|
||||||
|
CachedMutationsCompanion.insert(
|
||||||
|
kind: kind,
|
||||||
|
payload: jsonEncode(payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Nudge the replayer in case we're online right now.
|
||||||
|
unawaited(_ref.read(mutationReplayerProvider).drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count of pending mutations (attempts < 5). Diagnostic / future
|
||||||
|
/// UI surface for a "syncing N pending changes" indicator.
|
||||||
|
Future<int> pendingCount() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.customSelect(
|
||||||
|
'SELECT COUNT(*) AS c FROM cached_mutations WHERE attempts < 5',
|
||||||
|
).get();
|
||||||
|
return rows.first.read<int>('c');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationQueueProvider =
|
||||||
|
Provider<MutationQueue>((ref) => MutationQueue(ref));
|
||||||
|
|
||||||
|
class MutationReplayer {
|
||||||
|
MutationReplayer(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Timer? _initialTimer;
|
||||||
|
Timer? _intervalTimer;
|
||||||
|
bool _running = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Periodic poll cadence. The connectivity listener catches most
|
||||||
|
/// transitions; this is the belt-and-suspenders for cases where
|
||||||
|
/// connectivity_plus misses a state change (e.g. flaky Wi-Fi).
|
||||||
|
static const _interval = Duration(minutes: 1);
|
||||||
|
|
||||||
|
/// Max replay attempts before we drop a mutation. 5 is enough to
|
||||||
|
/// span a few reconnection cycles without holding onto a forever-
|
||||||
|
/// broken request. See file header for the drop rationale.
|
||||||
|
static const _maxAttempts = 5;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
// Drain shortly after launch in case there are queued mutations
|
||||||
|
// from a prior session that died offline. 3s lets the auth +
|
||||||
|
// server-url + dio providers finish their async warmup.
|
||||||
|
_initialTimer = Timer(const Duration(seconds: 3), drain);
|
||||||
|
// Periodic ticker is the only reconnect signal. We previously had
|
||||||
|
// a ref.listen(connectivityProvider, …) edge trigger here, but
|
||||||
|
// subscribing at start-time eagerly mounted the connectivity
|
||||||
|
// StreamProvider and leaked its initial-timeout Timer through
|
||||||
|
// tests that never reach the auth state. The drain() loop itself
|
||||||
|
// gates on connectivity via the .future read below.
|
||||||
|
_intervalTimer = Timer.periodic(_interval, (_) => drain());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the queue oldest-first, replaying each mutation. Stops on
|
||||||
|
/// any transient network error (we'll retry on the next tick); a
|
||||||
|
/// permanent error (404 etc.) increments the attempt counter and
|
||||||
|
/// moves on to the next mutation. This is public so MutationQueue.
|
||||||
|
/// enqueue can trigger an immediate attempt after writing.
|
||||||
|
Future<void> drain() async {
|
||||||
|
if (_disposed || _running) return;
|
||||||
|
final online = await _ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
if (!online) return;
|
||||||
|
_running = true;
|
||||||
|
try {
|
||||||
|
while (!_disposed) {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final next = await (db.select(db.cachedMutations)
|
||||||
|
..where((t) => t.attempts.isSmallerThanValue(_maxAttempts))
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)])
|
||||||
|
..limit(1))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (next == null) break;
|
||||||
|
|
||||||
|
final handler = _handlers[next.kind];
|
||||||
|
if (handler == null) {
|
||||||
|
debugPrint('mutation_replayer: unknown kind ${next.kind}, dropping');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> payload;
|
||||||
|
try {
|
||||||
|
payload = jsonDecode(next.payload) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('mutation_replayer: bad payload for ${next.kind}: $e');
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(_ref, payload);
|
||||||
|
await (db.delete(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.go();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
if (_isTransient(e)) {
|
||||||
|
// Network is flaky again — bail and try the whole queue
|
||||||
|
// later. The current mutation will be retried next tick.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Permanent failure (4xx/5xx with a real response). Leave
|
||||||
|
// the row with bumped attempts and move on to the next so
|
||||||
|
// one bad mutation doesn't block the others.
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('mutation_replayer: unexpected error: $e\n$st');
|
||||||
|
await (db.update(db.cachedMutations)
|
||||||
|
..where((t) => t.id.equals(next.id)))
|
||||||
|
.write(CachedMutationsCompanion(
|
||||||
|
attempts: Value(next.attempts + 1),
|
||||||
|
lastAttemptAt: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTransient(DioException e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_initialTimer?.cancel();
|
||||||
|
_intervalTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final mutationReplayerProvider = Provider<MutationReplayer>((ref) {
|
||||||
|
final r = MutationReplayer(ref);
|
||||||
|
ref.onDispose(r.dispose);
|
||||||
|
r.start();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Type signature for kind-specific replay handlers.
|
||||||
|
typedef _Handler = Future<void> Function(Ref, Map<String, dynamic>);
|
||||||
|
|
||||||
|
/// Kind → handler dispatch. Each handler decodes its payload and
|
||||||
|
/// re-fires the original REST call. Throws on failure (caught by the
|
||||||
|
/// replayer's drain loop above).
|
||||||
|
///
|
||||||
|
/// Payload shapes (see also enqueue call sites for the writer side):
|
||||||
|
/// * like.add / like.remove: {'kind': 'track'|'album'|'artist', 'id': uuid}
|
||||||
|
/// * quarantine.flag: {'trackId': uuid, 'reason': str, 'notes': str}
|
||||||
|
/// * quarantine.unflag: {'trackId': uuid}
|
||||||
|
/// * playlist.append: {'playlistId': uuid, 'trackIds': [uuid, …]}
|
||||||
|
/// * request.create: {full createRequest args; see DiscoverApi}
|
||||||
|
/// * request.cancel: {'id': uuid}
|
||||||
|
/// * play.offline: {'trackId': uuid, 'clientId': str, 'at': iso8601,
|
||||||
|
/// 'durationPlayedMs': int, 'source'?: 'for_you'|'discover'}
|
||||||
|
final Map<String, _Handler> _handlers = {
|
||||||
|
MutationKinds.likeAdd: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.like(_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.likeRemove: (ref, p) async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
await api.unlike(
|
||||||
|
_likeKindFromString(p['kind'] as String), p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineFlag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.flag(
|
||||||
|
p['trackId'] as String,
|
||||||
|
p['reason'] as String,
|
||||||
|
notes: (p['notes'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.quarantineUnflag: (ref, p) async {
|
||||||
|
final api = await ref.read(quarantineApiProvider.future);
|
||||||
|
await api.unflag(p['trackId'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.playlistAppend: (ref, p) async {
|
||||||
|
final api = await ref.read(playlistsApiProvider.future);
|
||||||
|
final ids = (p['trackIds'] as List).cast<String>();
|
||||||
|
await api.appendTracks(p['playlistId'] as String, ids);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCreate: (ref, p) async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final api = DiscoverApi(dio);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: _lidarrKindFromString(p['kind'] as String),
|
||||||
|
artistMbid: p['artistMbid'] as String,
|
||||||
|
artistName: p['artistName'] as String,
|
||||||
|
albumMbid: p['albumMbid'] as String?,
|
||||||
|
albumTitle: p['albumTitle'] as String?,
|
||||||
|
trackMbid: p['trackMbid'] as String?,
|
||||||
|
trackTitle: p['trackTitle'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MutationKinds.requestCancel: (ref, p) async {
|
||||||
|
final api = await ref.read(requestsApiProvider.future);
|
||||||
|
await api.cancel(p['id'] as String);
|
||||||
|
},
|
||||||
|
MutationKinds.playOffline: (ref, p) async {
|
||||||
|
final dio = await ref.read(dioProvider.future);
|
||||||
|
final api = EventsApi(dio);
|
||||||
|
await api.playOffline(
|
||||||
|
trackId: p['trackId'] as String,
|
||||||
|
clientId: p['clientId'] as String,
|
||||||
|
atIso: p['at'] as String,
|
||||||
|
durationPlayedMs: (p['durationPlayedMs'] as num).toInt(),
|
||||||
|
source: p['source'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
LikeKind _likeKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LikeKind.album,
|
||||||
|
'artist' => LikeKind.artist,
|
||||||
|
_ => LikeKind.track,
|
||||||
|
};
|
||||||
|
|
||||||
|
LidarrRequestKind _lidarrKindFromString(String s) => switch (s) {
|
||||||
|
'album' => LidarrRequestKind.album,
|
||||||
|
'track' => LidarrRequestKind.track,
|
||||||
|
_ => LidarrRequestKind.artist,
|
||||||
|
};
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
// Reachability-based offline marker (#427 S1).
|
||||||
|
//
|
||||||
|
// `connectivityProvider` only knows whether a network *interface* is
|
||||||
|
// up — not whether the Minstrel server is actually reachable (captive
|
||||||
|
// portals, server down, DNS, VPN-only routes all read "online"). This
|
||||||
|
// is the single source of truth other features gate on: offline =
|
||||||
|
// the server failed its /healthz probe N times in a row; recovery on
|
||||||
|
// the first success.
|
||||||
|
//
|
||||||
|
// Deliberately NOT coupled to connectivityProvider: subscribing to
|
||||||
|
// that StreamProvider eagerly mounts its 2s checkConnectivity timeout
|
||||||
|
// and leaks a pending Timer through widget tests that never reach the
|
||||||
|
// auth state (the bug fixed for MutationReplayer). /healthz failing
|
||||||
|
// already covers interface-down — it just fails the probe.
|
||||||
|
//
|
||||||
|
// Optimistic: assume online until proven offline, so a cold launch
|
||||||
|
// behaves normally and only flips after sustained unreachability.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/health.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
|
||||||
|
class OfflineMonitor extends Notifier<bool> {
|
||||||
|
Timer? _initial;
|
||||||
|
Timer? _periodic;
|
||||||
|
int _consecutiveFails = 0;
|
||||||
|
|
||||||
|
/// Consecutive failed probes before we declare offline. Small
|
||||||
|
/// enough to react within ~half a minute, large enough that a
|
||||||
|
/// single dropped request doesn't flip the whole UI.
|
||||||
|
static const _threshold = 3;
|
||||||
|
|
||||||
|
/// Probe cadence. Slower when believed-online (cheap heartbeat);
|
||||||
|
/// faster when offline so recovery is noticed quickly.
|
||||||
|
static const _onlineInterval = Duration(seconds: 30);
|
||||||
|
static const _offlineInterval = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Let auth + server-url + dio providers finish async warmup
|
||||||
|
/// before the first probe so a cold start doesn't false-positive.
|
||||||
|
static const _initialDelay = Duration(seconds: 5);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
_initial?.cancel();
|
||||||
|
_periodic?.cancel();
|
||||||
|
});
|
||||||
|
_initial = Timer(_initialDelay, () {
|
||||||
|
_check();
|
||||||
|
_arm();
|
||||||
|
});
|
||||||
|
return false; // optimistic until a probe says otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
void _arm() {
|
||||||
|
_periodic?.cancel();
|
||||||
|
_periodic = Timer.periodic(
|
||||||
|
state ? _offlineInterval : _onlineInterval,
|
||||||
|
(_) => _check(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _check() async {
|
||||||
|
final Dio dio;
|
||||||
|
try {
|
||||||
|
// Not configured yet (no server URL) → don't flip; the app is
|
||||||
|
// still on the connect screen and "offline" is meaningless.
|
||||||
|
dio = await ref.read(dioProvider.future);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// dio's own connect/receive timeouts bound this — no extra
|
||||||
|
// Timer (which would leak in widget tests).
|
||||||
|
await HealthApi(dio).check();
|
||||||
|
_consecutiveFails = 0;
|
||||||
|
_set(false);
|
||||||
|
} catch (_) {
|
||||||
|
_consecutiveFails++;
|
||||||
|
if (_consecutiveFails >= _threshold) {
|
||||||
|
_set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _set(bool offline) {
|
||||||
|
if (state == offline) return;
|
||||||
|
state = offline;
|
||||||
|
_arm(); // cadence follows the new state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force an immediate probe — e.g. a user-initiated retry. Public
|
||||||
|
/// so S4's offline surfaces can offer a "try again" affordance.
|
||||||
|
Future<void> recheck() => _check();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when the server is unreachable. Watch this to gate
|
||||||
|
/// online-only affordances; read it once at app start to activate
|
||||||
|
/// the poller.
|
||||||
|
final offlineProvider =
|
||||||
|
NotifierProvider<OfflineMonitor, bool>(OfflineMonitor.new);
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
|
import '../player/album_color_extractor.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);
|
||||||
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final colorCache = _ref.read(albumColorCacheProvider);
|
||||||
|
|
||||||
|
// Walk the window. For each upcoming track we want THREE things
|
||||||
|
// ready when the player transitions into it:
|
||||||
|
//
|
||||||
|
// 1. Audio file on disk (otherwise playback stalls on stream
|
||||||
|
// load — the original prefetcher concern).
|
||||||
|
// 2. Cover bytes on disk under AlbumCoverCache (otherwise
|
||||||
|
// _toMediaItem's peekCached returns null, mediaItem
|
||||||
|
// broadcasts with artUri=null, and the now-playing screen
|
||||||
|
// stalls in _scheduleSwap awaiting precacheImage of a file
|
||||||
|
// that doesn't exist yet).
|
||||||
|
// 3. Palette color extracted and memoized in AlbumColorCache
|
||||||
|
// (otherwise the gradient backdrop has to wait for
|
||||||
|
// PaletteGenerator to run after the cover lands —
|
||||||
|
// visible as the cover snapping in before the gradient).
|
||||||
|
//
|
||||||
|
// Each call is idempotent (cache-aware): if already cached, it's
|
||||||
|
// a no-op. Everything fire-and-forget so the reconcile completes
|
||||||
|
// quickly even on a fresh queue.
|
||||||
|
for (var i = currentIdx; i <= endIdx; i++) {
|
||||||
|
final media = queue[i];
|
||||||
|
final trackId = media.id;
|
||||||
|
final albumId = media.extras?['album_id'] as String?;
|
||||||
|
|
||||||
|
if (!await mgr.isCached(trackId)) {
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
mgr.pin(trackId, source: CacheSource.autoPrefetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albumId != null && albumId.isNotEmpty) {
|
||||||
|
// Cover bytes: getOrFetch returns the file path; the side
|
||||||
|
// effect (writing to disk) is what we care about.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
coverCache.getOrFetch(albumId);
|
||||||
|
// Palette: getOrExtract chains off coverCache.getOrFetch so
|
||||||
|
// it'll wait for the cover before sampling — safe to call
|
||||||
|
// in parallel here.
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
colorCache.getOrExtract(albumId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eviction pass after pinning new files. Per-bucket (#427 S2):
|
||||||
|
// liked-ness decides the bucket, so a track that's both liked
|
||||||
|
// and recently played is protected by the Liked cap, not the
|
||||||
|
// rolling LRU.
|
||||||
|
final liked =
|
||||||
|
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
await mgr.evictBuckets(
|
||||||
|
likedCap: settings.likedCapBytes,
|
||||||
|
rollingCap: settings.rollingCapBytes,
|
||||||
|
liked: liked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read this provider once at app start to activate the prefetcher.
|
||||||
|
/// Constructor wires the listeners.
|
||||||
|
final prefetcherProvider = Provider<Prefetcher>((ref) {
|
||||||
|
return Prefetcher(ref);
|
||||||
|
});
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
// Resume-on-launch (#54). The #52 teardown tears the audio_service
|
||||||
|
// session down (and clears mediaItem) when idle/dismissed, so the
|
||||||
|
// headset / lock-screen play button otherwise has nothing to resume.
|
||||||
|
// This controller persists the live queue + index + position + #415
|
||||||
|
// source to a single-row drift snapshot on track change / pause / app
|
||||||
|
// teardown, and on construction restores the last snapshot into the
|
||||||
|
// handler PAUSED (the user sees their last track and continues with
|
||||||
|
// play / a media button — we never auto-blast on launch).
|
||||||
|
//
|
||||||
|
// Persist guard: when the handler clears (teardown → mediaItem null /
|
||||||
|
// empty queue) we deliberately do NOT write, so the last good snapshot
|
||||||
|
// survives for the next launch — that survival is the whole point.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import '../player/player_provider.dart';
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'db.dart';
|
||||||
|
|
||||||
|
class ResumeController with WidgetsBindingObserver {
|
||||||
|
ResumeController(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
final _subs = <StreamSubscription<dynamic>>[];
|
||||||
|
Timer? _debounce;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
try {
|
||||||
|
// audioHandlerProvider throws until main() overrides it (the real
|
||||||
|
// app always does). In tests / no-audio environments there's
|
||||||
|
// nothing to resume — stay inert.
|
||||||
|
_ref.read(audioHandlerProvider);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
await _restore();
|
||||||
|
if (_disposed) return;
|
||||||
|
final h = _ref.read(audioHandlerProvider);
|
||||||
|
// Media-button-when-torn-down hook (#448): play() invokes this when
|
||||||
|
// mediaItem is null so a headset/watch press resumes the snapshot.
|
||||||
|
h.setResumeHook(resumeFromMediaButton);
|
||||||
|
_subs.add(h.mediaItem.listen((_) => _schedulePersist()));
|
||||||
|
_subs.add(h.playbackState.listen((_) => _schedulePersist()));
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _schedulePersist() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = Timer(const Duration(seconds: 3), () {
|
||||||
|
unawaited(_persist());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
final h = _ref.read(audioHandlerProvider);
|
||||||
|
final tracks = h.queuedTracks;
|
||||||
|
final mi = h.mediaItem.value;
|
||||||
|
// Cleared by teardown — keep the last good snapshot for next
|
||||||
|
// launch rather than wiping it with an empty queue.
|
||||||
|
if (tracks.isEmpty || mi == null) return;
|
||||||
|
var idx = tracks.indexWhere((t) => t.id == mi.id);
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
final blob = jsonEncode({
|
||||||
|
'v': 1,
|
||||||
|
'source': h.queueSource,
|
||||||
|
'index': idx,
|
||||||
|
'position_ms': h.position.inMilliseconds,
|
||||||
|
'tracks': tracks.map((t) => t.toJson()).toList(),
|
||||||
|
});
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
await db.into(db.cachedResumeState).insertOnConflictUpdate(
|
||||||
|
CachedResumeStateCompanion.insert(
|
||||||
|
json: blob,
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('resume_controller: persist failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch path: restore the last session PAUSED (no auto-blast).
|
||||||
|
Future<void> _restore() async {
|
||||||
|
try {
|
||||||
|
await _loadAndRestore();
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('resume_controller: restore failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media-button path (#448): the user pressed play on the headset /
|
||||||
|
/// watch / lock screen while the session was fully torn down (#52) and
|
||||||
|
/// nothing is loaded. Restore the snapshot, then START playback (they
|
||||||
|
/// asked to play). Registered as the handler's resume hook in start().
|
||||||
|
/// No-op if there's nothing to resume.
|
||||||
|
Future<void> resumeFromMediaButton() async {
|
||||||
|
try {
|
||||||
|
if (await _loadAndRestore()) {
|
||||||
|
await _ref.read(audioHandlerProvider).play();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('resume_controller: media-button resume failed: $e\n$st');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared loader. Restores the last persisted session PAUSED and
|
||||||
|
/// returns whether it actually restored a queue. Guards: an already-
|
||||||
|
/// active session (don't stomp), missing auth, no/empty snapshot.
|
||||||
|
Future<bool> _loadAndRestore() async {
|
||||||
|
final h = _ref.read(audioHandlerProvider);
|
||||||
|
if (h.mediaItem.value != null) return false;
|
||||||
|
final url = await _ref.read(serverUrlProvider.future);
|
||||||
|
final token =
|
||||||
|
await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||||
|
if (url == null || url.isEmpty || token == null || token.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final row = await db.select(db.cachedResumeState).getSingleOrNull();
|
||||||
|
if (row == null) return false;
|
||||||
|
final m = jsonDecode(row.json) as Map<String, dynamic>;
|
||||||
|
final rawTracks = (m['tracks'] as List?) ?? const [];
|
||||||
|
final tracks = rawTracks
|
||||||
|
.map((e) => TrackRef.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
if (tracks.isEmpty) return false;
|
||||||
|
final idx = (m['index'] as num?)?.toInt() ?? 0;
|
||||||
|
final posMs = (m['position_ms'] as num?)?.toInt() ?? 0;
|
||||||
|
final source = m['source'] as String?;
|
||||||
|
await _ref.read(playerActionsProvider).restoreQueue(
|
||||||
|
tracks,
|
||||||
|
initialIndex: idx,
|
||||||
|
position: Duration(milliseconds: posMs),
|
||||||
|
source: source,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// App backgrounded / killed: persist durably right now (the debounce
|
||||||
|
// may not fire before teardown).
|
||||||
|
if (state == AppLifecycleState.paused ||
|
||||||
|
state == AppLifecycleState.detached) {
|
||||||
|
_debounce?.cancel();
|
||||||
|
unawaited(_persist());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_debounce?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
for (final s in _subs) {
|
||||||
|
s.cancel();
|
||||||
|
}
|
||||||
|
_subs.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read once at app start (app.dart postFrame). On construction it
|
||||||
|
/// restores the last persisted session (paused) then persists
|
||||||
|
/// queue/index/position on track change, pause, and app teardown.
|
||||||
|
/// Disposed via ref.onDispose when the scope tears down.
|
||||||
|
final resumeControllerProvider = Provider<ResumeController>((ref) {
|
||||||
|
final c = ResumeController(ref);
|
||||||
|
ref.onDispose(c.dispose);
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
c.start();
|
||||||
|
return c;
|
||||||
|
});
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Offline play sources over the local cache index (#427 S4).
|
||||||
|
//
|
||||||
|
// "Shuffle all" is always-present and degrades with reachability:
|
||||||
|
// online → GET /api/library/shuffle (random over the whole library)
|
||||||
|
// offline → a client shuffle over the entire local cache index
|
||||||
|
//
|
||||||
|
// Offline-only pools surfaced on Home beside the (disabled) system
|
||||||
|
// playlists: "Recently played" (cache by lastPlayedAt desc) and
|
||||||
|
// "Liked" (cache ∩ liked set). All of these are UNIONs over the
|
||||||
|
// cache regardless of storage bucket — liked AND recently-played
|
||||||
|
// both included. The two-bucket split (S2) is storage/eviction-only
|
||||||
|
// and never filters playback, which is exactly what this relies on.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../library/library_providers.dart' show libraryApiProvider;
|
||||||
|
import '../likes/likes_provider.dart' show likedIdsProvider;
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'adapters.dart';
|
||||||
|
import 'audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import 'offline_provider.dart';
|
||||||
|
|
||||||
|
class ShuffleSource {
|
||||||
|
ShuffleSource(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
/// Shuffle-all. Online: server-random. Offline: every cached
|
||||||
|
/// track, shuffled. Empty offline → nothing cached yet.
|
||||||
|
Future<List<TrackRef>> tracks({int limit = 100}) async {
|
||||||
|
if (_ref.read(offlineProvider)) {
|
||||||
|
final ids = await _cachedIdsByRecency();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
list.shuffle(Random());
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
final api = await _ref.read(libraryApiProvider.future);
|
||||||
|
return api.shuffle(limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached tracks, most-recently-played first (liked included).
|
||||||
|
/// Cache-only — surfaced on Home only when offline.
|
||||||
|
Future<List<TrackRef>> recentlyPlayed({int limit = 100}) async {
|
||||||
|
final ids = await _cachedIdsByRecency();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached tracks that are in the user's liked set. Cache-only —
|
||||||
|
/// surfaced on Home only when offline.
|
||||||
|
Future<List<TrackRef>> liked({int limit = 100}) async {
|
||||||
|
final likedSet =
|
||||||
|
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
|
||||||
|
final ids =
|
||||||
|
(await _cachedIdsByRecency()).where(likedSet.contains).toList();
|
||||||
|
final list = await _refs(ids);
|
||||||
|
return list.length > limit ? list.sublist(0, limit) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached track ids ordered by lastPlayedAt desc (nulls last so
|
||||||
|
/// never-touched downloads sort after real plays).
|
||||||
|
Future<List<String>> _cachedIdsByRecency() async {
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final rows = await db.select(db.audioCacheIndex).get();
|
||||||
|
rows.sort((a, b) {
|
||||||
|
final av = a.lastPlayedAt, bv = b.lastPlayedAt;
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
return bv.compareTo(av);
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.trackId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializes ordered track ids into TrackRefs from the cached
|
||||||
|
/// metadata tables, preserving the given order.
|
||||||
|
Future<List<TrackRef>> _refs(List<String> orderedIds) async {
|
||||||
|
if (orderedIds.isEmpty) return const [];
|
||||||
|
final db = _ref.read(appDbProvider);
|
||||||
|
final meta = await (db.select(db.cachedTracks)
|
||||||
|
..where((t) => t.id.isIn(orderedIds)))
|
||||||
|
.get();
|
||||||
|
if (meta.isEmpty) return const [];
|
||||||
|
final byId = {for (final t in meta) t.id: t};
|
||||||
|
final artistName = {
|
||||||
|
for (final a in await db.select(db.cachedArtists).get()) a.id: a.name
|
||||||
|
};
|
||||||
|
final albumTitle = {
|
||||||
|
for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
for (final id in orderedIds)
|
||||||
|
if (byId[id] case final t?)
|
||||||
|
t.toRef(
|
||||||
|
artistName: artistName[t.artistId] ?? '',
|
||||||
|
albumTitle: albumTitle[t.albumId] ?? '',
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final shuffleSourceProvider =
|
||||||
|
Provider<ShuffleSource>((ref) => ShuffleSource(ref));
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider;
|
||||||
|
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;
|
||||||
|
// IDs of upserted entities whose covers we'll pre-warm after the
|
||||||
|
// transaction commits. Filled inside the loops; consumed by
|
||||||
|
// _prewarmCovers fire-and-forget below. Artist covers are
|
||||||
|
// server-derived from "most-recent album" and aren't
|
||||||
|
// reconstructible client-side — album pre-warm already covers an
|
||||||
|
// artist's primary visual via its detail-page header.
|
||||||
|
final albumCoverIds = <String>[];
|
||||||
|
final playlistCoverIds = <String>[];
|
||||||
|
|
||||||
|
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 [])) {
|
||||||
|
final m = a as Map<String, dynamic>;
|
||||||
|
await db.into(db.cachedAlbums).insertOnConflictUpdate(
|
||||||
|
_albumFromJson(m),
|
||||||
|
);
|
||||||
|
final id = m['id'];
|
||||||
|
if (id is String && id.isNotEmpty) albumCoverIds.add(id);
|
||||||
|
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 [])) {
|
||||||
|
final m = p as Map<String, dynamic>;
|
||||||
|
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
|
||||||
|
_playlistFromJson(m),
|
||||||
|
);
|
||||||
|
final id = m['id'];
|
||||||
|
if (id is String && id.isNotEmpty) playlistCoverIds.add(id);
|
||||||
|
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()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget cover pre-warm so a cold-start scroll through
|
||||||
|
// the home grid paints from disk on the very first frame instead
|
||||||
|
// of firing one HTTP request per visible tile. Unawaited because
|
||||||
|
// the sync's "done" signal should fire as soon as the metadata
|
||||||
|
// delta is durable; cover downloads can finish in the background.
|
||||||
|
if (albumCoverIds.isNotEmpty || playlistCoverIds.isNotEmpty) {
|
||||||
|
unawaited(_prewarmCovers(albumCoverIds, playlistCoverIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = SyncResult(
|
||||||
|
upserts: upsertCount,
|
||||||
|
deletes: deleteCount,
|
||||||
|
cursor: newCursor,
|
||||||
|
);
|
||||||
|
state = AsyncData(result);
|
||||||
|
return result;
|
||||||
|
} catch (e, st) {
|
||||||
|
state = AsyncError(e, st);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads cover bytes for each id into the shared flutter_cache_
|
||||||
|
/// manager disk cache that cached_network_image reads from.
|
||||||
|
/// Best-effort: per-URL failures (404 collage-not-built-yet, 401
|
||||||
|
/// during a token refresh race, network blip) are swallowed so one
|
||||||
|
/// missing cover doesn't abort the rest.
|
||||||
|
///
|
||||||
|
/// Concurrency 3 balances disk-warming throughput against starving
|
||||||
|
/// foreground UI work — covers are ~30-200KB each, so three in
|
||||||
|
/// flight saturates most LAN connections without pinning the radio.
|
||||||
|
Future<void> _prewarmCovers(
|
||||||
|
List<String> albumIds,
|
||||||
|
List<String> playlistIds,
|
||||||
|
) async {
|
||||||
|
final baseUrl = await ref.read(serverUrlProvider.future);
|
||||||
|
if (baseUrl == null || baseUrl.isEmpty) return;
|
||||||
|
final token = await ref.read(sessionTokenProvider.future);
|
||||||
|
final headers = (token != null && token.isNotEmpty)
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: <String, String>{};
|
||||||
|
final base =
|
||||||
|
baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl;
|
||||||
|
final urls = <String>[
|
||||||
|
for (final id in albumIds) '$base/api/albums/$id/cover',
|
||||||
|
for (final id in playlistIds) '$base/api/playlists/$id/cover',
|
||||||
|
];
|
||||||
|
final mgr = DefaultCacheManager();
|
||||||
|
var index = 0;
|
||||||
|
Future<void> worker() async {
|
||||||
|
while (index < urls.length) {
|
||||||
|
final i = index++;
|
||||||
|
try {
|
||||||
|
await mgr.downloadFile(urls[i], authHeaders: headers);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort prewarm — failures are expected for system
|
||||||
|
// playlists whose collage hasn't been built yet, and for
|
||||||
|
// any transient auth race. Don't surface or abort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future.wait(List.generate(3, (_) => worker()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
// Per-entity tile providers for the per-item rendering architecture
|
||||||
|
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
|
||||||
|
//
|
||||||
|
// Each provider is a StreamProvider.family<EntityRef?, String> keyed
|
||||||
|
// by entity id. The stream:
|
||||||
|
// 1. Watches the entity's drift row
|
||||||
|
// 2. Yields the populated row on every emission
|
||||||
|
// 3. Yields null while the row is absent (UI shows skeleton)
|
||||||
|
// 4. On the first missing-row emission, enqueues a hydration via
|
||||||
|
// HydrationQueue so the row eventually lands
|
||||||
|
//
|
||||||
|
// The tile widget reacts to AsyncValue<EntityRef?>:
|
||||||
|
// - loading or data:null → skeleton
|
||||||
|
// - data:non-null → real card
|
||||||
|
// - error → error placeholder
|
||||||
|
//
|
||||||
|
// Subscriptions are per-tile. A 50-tile home screen creates 50 drift
|
||||||
|
// subscriptions; drift handles this fine in practice but worth
|
||||||
|
// measuring if a future surface scales to hundreds.
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift show OrderingTerm;
|
||||||
|
import 'package:drift/drift.dart' show leftOuterJoin;
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'hydration_queue.dart';
|
||||||
|
|
||||||
|
/// Watches the cached_albums row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final albumTileProvider =
|
||||||
|
StreamProvider.family<AlbumRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedAlbums)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'album', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final album = r.readTable(db.cachedAlbums);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
yield album.toRef(artistName: artist?.name ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_artists row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
///
|
||||||
|
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
|
||||||
|
/// artist carries a representative album id; toRef() reconstructs
|
||||||
|
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
|
||||||
|
/// has an empty coverUrl and tiles fall back to the music-notes
|
||||||
|
/// placeholder even though the server has the cover available.
|
||||||
|
final artistTileProvider =
|
||||||
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'artist', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// First row has the alphabetically-first album (or null if artist
|
||||||
|
// has no albums in drift yet — yields a coverless ArtistRef which
|
||||||
|
// the UI falls back to the placeholder icon for).
|
||||||
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Watches the cached_tracks row for [id]. Triggers background
|
||||||
|
/// hydration on miss; yields the row once it lands.
|
||||||
|
final trackTileProvider =
|
||||||
|
StreamProvider.family<TrackRef?, String>((ref, id) async* {
|
||||||
|
if (id.isEmpty) {
|
||||||
|
yield null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedTracks)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||||
|
leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||||
|
]);
|
||||||
|
var enqueued = false;
|
||||||
|
await for (final rows in query.watch()) {
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
if (!enqueued) {
|
||||||
|
enqueued = true;
|
||||||
|
ref
|
||||||
|
.read(hydrationQueueProvider)
|
||||||
|
.enqueue(entityType: 'track', entityId: id);
|
||||||
|
}
|
||||||
|
yield null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final r = rows.first;
|
||||||
|
final t = r.readTable(db.cachedTracks);
|
||||||
|
final a = r.readTableOrNull(db.cachedArtists);
|
||||||
|
final al = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
yield t.toRef(
|
||||||
|
artistName: a?.name ?? '',
|
||||||
|
albumTitle: al?.title ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/discover.dart';
|
||||||
|
import '../api/errors.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../models/artist_suggestion.dart';
|
||||||
|
import '../models/lidarr.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
final _discoverApiProvider = FutureProvider<DiscoverApi>((ref) async {
|
||||||
|
return DiscoverApi(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
|
|
||||||
|
class DiscoverScreen extends ConsumerStatefulWidget {
|
||||||
|
const DiscoverScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<DiscoverScreen> createState() => _DiscoverScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
|
||||||
|
final _ctrl = TextEditingController();
|
||||||
|
LidarrRequestKind _kind = LidarrRequestKind.artist;
|
||||||
|
Future<List<LidarrSearchResult>>? _resultsFuture;
|
||||||
|
// Default (empty-search) surface: LB-derived out-of-library artist
|
||||||
|
// suggestions, mirroring web's SuggestionFeed.
|
||||||
|
Future<List<ArtistSuggestion>>? _suggestionsFuture;
|
||||||
|
final _requested = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSuggestions();
|
||||||
|
// Clearing the box returns to suggestions (web swaps live too).
|
||||||
|
_ctrl.addListener(() {
|
||||||
|
if (_ctrl.text.trim().isEmpty && _resultsFuture != null) {
|
||||||
|
setState(() => _resultsFuture = null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigns the future only; callers trigger the rebuild (initState
|
||||||
|
// runs before first build, so setState here would be a no-op/warn).
|
||||||
|
void _loadSuggestions() {
|
||||||
|
_suggestionsFuture = ref
|
||||||
|
.read(_discoverApiProvider.future)
|
||||||
|
.then((api) => api.listSuggestions());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestSuggestion(ArtistSuggestion s) async {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final args = {
|
||||||
|
'kind': LidarrRequestKind.artist.wire,
|
||||||
|
'artistMbid': s.mbid,
|
||||||
|
'artistName': s.name,
|
||||||
|
'albumMbid': null,
|
||||||
|
'albumTitle': null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_discoverApiProvider.future);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: LidarrRequestKind.artist,
|
||||||
|
artistMbid: s.mbid,
|
||||||
|
artistName: s.name,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
// Reassign the future first; the setState below rebuilds and
|
||||||
|
// the FutureBuilder picks up the fresh fetch (server now
|
||||||
|
// filters this candidate out). _requested hides it meanwhile.
|
||||||
|
_loadSuggestions();
|
||||||
|
setState(() => _requested.add(s.mbid));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Requested: ${s.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} on DioException catch (_) {
|
||||||
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCreate, args);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _requested.add(s.mbid));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Request queued: ${s.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runSearch() {
|
||||||
|
final q = _ctrl.text.trim();
|
||||||
|
if (q.isEmpty) {
|
||||||
|
setState(() => _resultsFuture = null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_resultsFuture = ref
|
||||||
|
.read(_discoverApiProvider.future)
|
||||||
|
.then((api) => api.search(q, _kind));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _request(LidarrSearchResult row) async {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final args = {
|
||||||
|
'kind': _kind.wire,
|
||||||
|
'artistMbid':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.mbid : row.artistMbid,
|
||||||
|
'artistName':
|
||||||
|
_kind == LidarrRequestKind.artist ? row.name : row.secondaryText,
|
||||||
|
'albumMbid': _kind == LidarrRequestKind.album ? row.mbid : null,
|
||||||
|
'albumTitle': _kind == LidarrRequestKind.album ? row.name : null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final api = await ref.read(_discoverApiProvider.future);
|
||||||
|
await api.createRequest(
|
||||||
|
kind: _kind,
|
||||||
|
artistMbid: args['artistMbid'] as String,
|
||||||
|
artistName: args['artistName'] as String,
|
||||||
|
albumMbid: args['albumMbid'],
|
||||||
|
albumTitle: args['albumTitle'],
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Requested: ${row.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
// Re-run search to refresh the `requested` flag on the row.
|
||||||
|
_runSearch();
|
||||||
|
}
|
||||||
|
} on DioException catch (_) {
|
||||||
|
// Queue for replay so the user's request persists across
|
||||||
|
// network loss. We don't have a drift table for in-flight
|
||||||
|
// requests yet, so the row won't show on the Requests screen
|
||||||
|
// until replay succeeds — accept that for v1.
|
||||||
|
await ref
|
||||||
|
.read(mutationQueueProvider)
|
||||||
|
.enqueue(MutationKinds.requestCreate, args);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('Request queued: ${row.name}'),
|
||||||
|
backgroundColor: fs.iron,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(LucideIcons.arrow_left, color: fs.parchment),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title: Text('Discover', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/discover')],
|
||||||
|
),
|
||||||
|
body: Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
style: TextStyle(color: fs.parchment),
|
||||||
|
cursorColor: fs.accent,
|
||||||
|
onSubmitted: (_) => _runSearch(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search Lidarr for new music',
|
||||||
|
hintStyle: TextStyle(color: fs.ash),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: fs.iron),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderSide: BorderSide(color: fs.accent),
|
||||||
|
),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(LucideIcons.search, color: fs.ash),
|
||||||
|
onPressed: _runSearch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: SegmentedButton<LidarrRequestKind>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: LidarrRequestKind.artist, label: Text('Artists')),
|
||||||
|
ButtonSegment(value: LidarrRequestKind.album, label: Text('Albums')),
|
||||||
|
],
|
||||||
|
selected: {_kind},
|
||||||
|
onSelectionChanged: (s) {
|
||||||
|
setState(() {
|
||||||
|
_kind = s.first;
|
||||||
|
if (_ctrl.text.trim().isNotEmpty) _runSearch();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _resultsFuture == null
|
||||||
|
? _buildSuggestions(fs)
|
||||||
|
: FutureBuilder<List<LidarrSearchResult>>(
|
||||||
|
future: _resultsFuture,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snap.hasError) {
|
||||||
|
final err = snap.error;
|
||||||
|
final code = err is DioException
|
||||||
|
? ApiError.fromDio(err).code
|
||||||
|
: 'unknown';
|
||||||
|
return Center(
|
||||||
|
child: Text('Search failed: $code',
|
||||||
|
style: TextStyle(color: fs.error)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final rows = snap.data ?? const [];
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text('Nothing to add for that search yet.',
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: rows.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
Divider(height: 1, color: fs.iron),
|
||||||
|
itemBuilder: (ctx, i) => _ResultTile(
|
||||||
|
row: rows[i],
|
||||||
|
onRequest: () => _request(rows[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSuggestions(FabledSwordTheme fs) {
|
||||||
|
return FutureBuilder<List<ArtistSuggestion>>(
|
||||||
|
future: _suggestionsFuture,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (snap.connectionState != ConnectionState.done) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final items = (snap.data ?? const <ArtistSuggestion>[])
|
||||||
|
.where((s) => !_requested.contains(s.mbid))
|
||||||
|
.toList(growable: false);
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Suggested for you',
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
"Out-of-library artists drawn from what you've liked and played.",
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (snap.hasError)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text("Couldn't load suggestions.",
|
||||||
|
style: TextStyle(color: fs.ash)),
|
||||||
|
)
|
||||||
|
else if (items.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Listen to something or like an artist to start getting suggestions.',
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...items.map((s) => _SuggestionTile(
|
||||||
|
s: s,
|
||||||
|
onRequest: () => _requestSuggestion(s),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResultTile extends StatelessWidget {
|
||||||
|
const _ResultTile({required this.row, required this.onRequest});
|
||||||
|
final LidarrSearchResult row;
|
||||||
|
final VoidCallback onRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final disabled = row.inLibrary || row.requested;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: fs.slate,
|
||||||
|
child: row.imageUrl.isEmpty
|
||||||
|
? Icon(LucideIcons.disc_3, color: fs.ash)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: row.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(LucideIcons.disc_3, color: fs.ash),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(row.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||||
|
if (row.secondaryText.isNotEmpty)
|
||||||
|
Text(row.secondaryText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (row.inLibrary)
|
||||||
|
_Pill(label: 'In library', color: fs.ash)
|
||||||
|
else if (row.requested)
|
||||||
|
_Pill(label: 'Requested', color: fs.ash)
|
||||||
|
else
|
||||||
|
FilledButton(
|
||||||
|
onPressed: disabled ? null : onRequest,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: fs.accent,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
child: const Text('Request'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pill extends StatelessWidget {
|
||||||
|
const _Pill({required this.label, required this.color});
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.iron,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(color: color, fontSize: 11)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SuggestionTile extends StatelessWidget {
|
||||||
|
const _SuggestionTile({required this.s, required this.onRequest});
|
||||||
|
final ArtistSuggestion s;
|
||||||
|
final VoidCallback onRequest;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: fs.slate,
|
||||||
|
child: s.imageUrl.isEmpty
|
||||||
|
? Icon(LucideIcons.user, color: fs.ash)
|
||||||
|
: CachedNetworkImage(
|
||||||
|
imageUrl: s.imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: const Duration(milliseconds: 120),
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(LucideIcons.user, color: fs.ash),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(s.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||||
|
if (s.attributionText.isNotEmpty)
|
||||||
|
Text(s.attributionText,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onRequest,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: fs.accent,
|
||||||
|
foregroundColor: fs.parchment,
|
||||||
|
),
|
||||||
|
child: const Text('Request'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,105 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../models/album.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/track_row.dart';
|
import 'widgets/track_row.dart';
|
||||||
|
|
||||||
class AlbumDetailScreen extends ConsumerWidget {
|
class AlbumDetailScreen extends ConsumerWidget {
|
||||||
const AlbumDetailScreen({required this.id, super.key});
|
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional album reference passed by the caller (typically the
|
||||||
|
/// tile they tapped) so the header can render immediately while
|
||||||
|
/// the full provider loads tracks. Hydration only — provider data
|
||||||
|
/// still wins once it arrives.
|
||||||
|
final AlbumRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final live = ref.watch(albumProvider(id));
|
||||||
|
|
||||||
|
// Pick the best header info available: live data > seed > nothing.
|
||||||
|
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||||
|
? live.value!.album.title
|
||||||
|
: seed?.title ?? '';
|
||||||
|
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||||
|
? live.value!.album.artistName
|
||||||
|
: seed?.artistName ?? '';
|
||||||
|
|
||||||
|
Widget header() => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: ServerImage(
|
||||||
|
url: '/api/albums/$id/cover',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
headerTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment,
|
||||||
|
fontFamily: fs.display.fontFamily,
|
||||||
|
fontSize: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (headerArtist.isNotEmpty)
|
||||||
|
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: ref.watch(albumProvider(id)).when(
|
body: live.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => seed != null
|
||||||
|
? ListView(children: [
|
||||||
|
header(),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: const Center(child: CircularProgressIndicator()),
|
||||||
data: (r) => ListView(children: [
|
data: (r) => ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(width: 96, height: 96, color: fs.slate),
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: ServerImage(
|
||||||
|
url: '/api/albums/$id/cover',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: Column(
|
Expanded(child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -42,7 +116,7 @@ class AlbumDetailScreen extends ConsumerWidget {
|
|||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
icon: Icon(LucideIcons.play, color: fs.parchment),
|
||||||
onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks),
|
onPressed: () => ref.read(playerActionsProvider).playTracks(r.tracks),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,38 +1,80 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
import '../likes/like_button.dart';
|
import '../likes/like_button.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
|
|
||||||
class ArtistDetailScreen extends ConsumerWidget {
|
class ArtistDetailScreen extends ConsumerWidget {
|
||||||
const ArtistDetailScreen({required this.id, super.key});
|
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
|
/// Optional artist reference from the caller. Lets the screen render
|
||||||
|
/// the name + avatar immediately while albums load.
|
||||||
|
final ArtistRef? seed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final artist = ref.watch(artistProvider(id));
|
final artist = ref.watch(artistProvider(id));
|
||||||
final albums = ref.watch(artistAlbumsProvider(id));
|
final albums = ref.watch(artistAlbumsProvider(id));
|
||||||
|
|
||||||
|
// Resolve which artist info to render in the header. Live wins
|
||||||
|
// when present and populated; seed fills the gap during the
|
||||||
|
// first frame after navigation.
|
||||||
|
final liveArtist = artist.value;
|
||||||
|
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||||
|
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
body: artist.when(
|
body: artist.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
// While loading: render header from seed if available so the
|
||||||
data: (a) => ListView(children: [
|
// page isn't blank.
|
||||||
|
loading: () => seed == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _artistBody(context, ref, seed!, albums, fs),
|
||||||
|
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _artistBody(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ArtistRef a,
|
||||||
|
AsyncValue<List<AlbumRef>> albums,
|
||||||
|
FabledSwordTheme fs,
|
||||||
|
) =>
|
||||||
|
ListView(children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(
|
ClipOval(
|
||||||
width: 96, height: 96,
|
child: SizedBox(
|
||||||
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
// Server derives artist cover from a representative
|
||||||
|
// album. Drift cache doesn't persist that pointer, so
|
||||||
|
// mirror the trick client-side: reuse the first album
|
||||||
|
// returned by artistAlbumsProvider. Falls back to
|
||||||
|
// slate while albums are loading or empty.
|
||||||
|
child: _ArtistAvatar(
|
||||||
|
serverCoverUrl: a.coverUrl,
|
||||||
|
albums: albums,
|
||||||
|
fs: fs,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -49,12 +91,33 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
icon: Icon(LucideIcons.play, color: fs.parchment),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
try {
|
||||||
if (tracks.isEmpty) return;
|
final tracks =
|
||||||
final shuffled = [...tracks]..shuffle();
|
await ref.read(artistTracksProvider(id).future);
|
||||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
if (tracks.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No tracks found for this artist yet.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final shuffled = [...tracks]..shuffle();
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(shuffled);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('artist_detail: play failed: $e');
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Couldn't start playback: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -68,23 +131,79 @@ class ArtistDetailScreen extends ConsumerWidget {
|
|||||||
albums.when(
|
albums.when(
|
||||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||||
data: (list) => GridView.builder(
|
data: (list) {
|
||||||
shrinkWrap: true,
|
return LayoutBuilder(builder: (ctx, constraints) {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
const cols = 3;
|
||||||
padding: const EdgeInsets.all(8),
|
const sidePad = 8.0;
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
const gap = 8.0;
|
||||||
crossAxisCount: 2,
|
final cellW =
|
||||||
childAspectRatio: 0.8,
|
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||||
),
|
cols;
|
||||||
itemCount: list.length,
|
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||||
itemBuilder: (_, i) {
|
// ≈ 36) + slack. Artist line is suppressed in this
|
||||||
final AlbumRef album = list[i];
|
// grid (showArtist: false) since the page header already
|
||||||
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
|
// names the artist. Slack is generous on purpose — line-
|
||||||
},
|
// height variations would otherwise overflow by 1px.
|
||||||
),
|
final cellH = (cellW - 16) + 8 + 36 + 8;
|
||||||
|
return GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisExtent: cellH,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
),
|
||||||
|
itemCount: list.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final AlbumRef album = list[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
showArtist: false,
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/albums/${album.id}', extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]),
|
]);
|
||||||
),
|
}
|
||||||
|
|
||||||
|
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||||
|
/// non-empty; otherwise we mirror the server's "use the first album's
|
||||||
|
/// cover" rule client-side via the loaded album list.
|
||||||
|
class _ArtistAvatar extends StatelessWidget {
|
||||||
|
const _ArtistAvatar({
|
||||||
|
required this.serverCoverUrl,
|
||||||
|
required this.albums,
|
||||||
|
required this.fs,
|
||||||
|
});
|
||||||
|
final String serverCoverUrl;
|
||||||
|
final AsyncValue<List<AlbumRef>> albums;
|
||||||
|
final FabledSwordTheme fs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (serverCoverUrl.isNotEmpty) {
|
||||||
|
return ServerImage(
|
||||||
|
url: serverCoverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
||||||
|
if (firstId == null) {
|
||||||
|
return Container(color: fs.slate);
|
||||||
|
}
|
||||||
|
return ServerImage(
|
||||||
|
url: '/api/albums/$firstId/cover',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +1,609 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../api/errors.dart';
|
import '../api/errors.dart';
|
||||||
import '../models/album.dart';
|
import '../cache/offline_provider.dart';
|
||||||
import '../models/artist.dart';
|
import '../cache/shuffle_source.dart';
|
||||||
|
import '../cache/tile_providers.dart';
|
||||||
|
import '../models/playlist.dart';
|
||||||
|
import '../models/system_playlists_status.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
import '../player/player_provider.dart';
|
import '../player/player_provider.dart';
|
||||||
|
import '../playlists/playlists_provider.dart';
|
||||||
|
import '../playlists/widgets/playlist_card.dart';
|
||||||
|
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||||
import '../shared/widgets/connection_error_banner.dart';
|
import '../shared/widgets/connection_error_banner.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/skeletons.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'library_providers.dart';
|
import 'library_providers.dart';
|
||||||
import 'widgets/album_card.dart';
|
import 'widgets/album_card.dart';
|
||||||
import 'widgets/artist_card.dart';
|
import 'widgets/artist_card.dart';
|
||||||
|
import 'widgets/compact_track_card.dart';
|
||||||
import 'widgets/horizontal_scroll_row.dart';
|
import 'widgets/horizontal_scroll_row.dart';
|
||||||
import 'widgets/track_row.dart';
|
|
||||||
|
|
||||||
|
/// Home screen. Per-item rendering: a tiny /api/home/index discovery
|
||||||
|
/// fetch returns just section→IDs, then each tile hydrates itself
|
||||||
|
/// against the per-entity drift tables (sync-populated) with REST
|
||||||
|
/// fallback via the HydrationQueue. Cold-visit dead air shrinks to a
|
||||||
|
/// single small round-trip; tiles materialize as their data lands.
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final index = ref.watch(homeIndexProvider);
|
||||||
|
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
||||||
|
final status = ref.watch(systemPlaylistsStatusProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Minstrel', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||||
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ref.watch(homeProvider).when(
|
child: index.when(
|
||||||
error: (e, _) {
|
error: (e, _) {
|
||||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||||
if (code == 'connection_refused') {
|
if (code == 'connection_refused') {
|
||||||
return ConnectionErrorBanner(
|
return ConnectionErrorBanner(
|
||||||
onRetry: () => ref.refresh(homeProvider),
|
onRetry: () => ref.refresh(homeIndexProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => _HomeSkeleton(fs: fs),
|
||||||
data: (h) => RefreshIndicator(
|
data: (h) => RefreshIndicator(
|
||||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
onRefresh: () async => ref.refresh(homeIndexProvider.future),
|
||||||
child: ListView(children: [
|
child: ListView(
|
||||||
_albumsRow(context, 'Recently added', h.recentlyAddedAlbums),
|
physics: const ClampingScrollPhysics(),
|
||||||
_albumsRow(context, 'Rediscover', h.rediscoverAlbums),
|
children: [
|
||||||
_artistsRow(context, 'Rediscover artists', h.rediscoverArtists),
|
_PlaylistsSection(
|
||||||
_tracksRow(context, ref, 'Most played', h.mostPlayedTracks),
|
playlists: allPlaylists.value?.owned ?? const [],
|
||||||
_artistsRow(context, 'Last played artists', h.lastPlayedArtists),
|
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||||
const SizedBox(height: 96),
|
offline: ref.watch(offlineProvider),
|
||||||
]),
|
),
|
||||||
),
|
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
||||||
|
_RediscoverSection(
|
||||||
|
albumIds: h.rediscoverAlbums,
|
||||||
|
artistIds: h.rediscoverArtists,
|
||||||
|
),
|
||||||
|
_MostPlayedSection(ids: h.mostPlayedTracks),
|
||||||
|
_LastPlayedSection(ids: h.lastPlayedArtists),
|
||||||
|
const SizedBox(height: 140),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Widget _albumsRow(BuildContext ctx, String title, List<AlbumRef> albums) =>
|
|
||||||
HorizontalScrollRow(
|
// ─── Per-tile widgets ────────────────────────────────────────────────
|
||||||
title: title,
|
|
||||||
children: [
|
/// Duration of the skeleton→content cross-fade. 220ms reads as "tile
|
||||||
for (final a in albums)
|
/// settled into place" — longer drags, shorter feels like a hard cut.
|
||||||
AlbumCard(album: a, onTap: () => ctx.push('/albums/${a.id}')),
|
/// Each tile cross-fades independently when its data lands, so the
|
||||||
],
|
/// natural cascade from the hydration queue's bounded concurrency
|
||||||
);
|
/// produces a staged-reveal feel without any per-tile delay math.
|
||||||
|
const Duration _tileRevealDuration = Duration(milliseconds: 220);
|
||||||
Widget _artistsRow(BuildContext ctx, String title, List<ArtistRef> artists) =>
|
|
||||||
HorizontalScrollRow(
|
/// Album tile: skeleton until albumTileProvider yields a populated row.
|
||||||
title: title,
|
class _AlbumTile extends ConsumerWidget {
|
||||||
children: [
|
const _AlbumTile({required this.id});
|
||||||
for (final a in artists)
|
final String id;
|
||||||
ArtistCard(artist: a, onTap: () => ctx.push('/artists/${a.id}')),
|
|
||||||
],
|
@override
|
||||||
);
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asyncAlbum = ref.watch(albumTileProvider(id));
|
||||||
Widget _tracksRow(BuildContext ctx, WidgetRef ref, String title, List<TrackRef> tracks) =>
|
final album = asyncAlbum.asData?.value;
|
||||||
HorizontalScrollRow(
|
return AnimatedSwitcher(
|
||||||
title: title,
|
duration: _tileRevealDuration,
|
||||||
height: 64,
|
switchInCurve: Curves.easeOut,
|
||||||
children: [
|
child: album == null
|
||||||
for (final t in tracks)
|
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
|
||||||
SizedBox(
|
: AlbumCard(
|
||||||
width: 280,
|
key: ValueKey('album-${album.id}'),
|
||||||
child: TrackRow(
|
album: album,
|
||||||
track: t,
|
onTap: () =>
|
||||||
onTap: () => ref.read(playerActionsProvider).playTracks([t]),
|
context.push('/albums/${album.id}', extra: album),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
/// Artist tile: skeleton until artistTileProvider yields a populated row.
|
||||||
|
class _ArtistTile extends ConsumerWidget {
|
||||||
|
const _ArtistTile({required this.id});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asyncArtist = ref.watch(artistTileProvider(id));
|
||||||
|
final artist = asyncArtist.asData?.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: _tileRevealDuration,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
child: artist == null
|
||||||
|
? const SkeletonArtistTile(key: ValueKey('skeleton'))
|
||||||
|
: ArtistCard(
|
||||||
|
key: ValueKey('artist-${artist.id}'),
|
||||||
|
artist: artist,
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/artists/${artist.id}', extra: artist),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track tile (used by Most-Played). On tap, gathers the currently-
|
||||||
|
/// hydrated TrackRefs for the whole section so playback flows like
|
||||||
|
/// it did with the bulk-loaded list. Tracks that haven't hydrated yet
|
||||||
|
/// are skipped from the play list — typically transient on a cold
|
||||||
|
/// visit since hydration runs in parallel and finishes quickly.
|
||||||
|
class _TrackTile extends ConsumerWidget {
|
||||||
|
const _TrackTile({required this.id, required this.sectionIds});
|
||||||
|
final String id;
|
||||||
|
final List<String> sectionIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asyncTrack = ref.watch(trackTileProvider(id));
|
||||||
|
final track = asyncTrack.asData?.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: _tileRevealDuration,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
child: track == null
|
||||||
|
? const _CompactTrackSkeleton(key: ValueKey('skeleton'))
|
||||||
|
: CompactTrackCard(
|
||||||
|
key: ValueKey('track-${track.id}'),
|
||||||
|
track: track,
|
||||||
|
sectionTracks: _resolveSectionTracks(ref, sectionIds),
|
||||||
|
index:
|
||||||
|
sectionIds.indexOf(id).clamp(0, sectionIds.length - 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshots the section's current track states. Used at tile
|
||||||
|
/// construction time — CompactTrackCard's onTap uses it as the play
|
||||||
|
/// queue. Tracks still hydrating are dropped; once they land, a
|
||||||
|
/// rebuild re-runs this lookup so the queue grows naturally.
|
||||||
|
static List<TrackRef> _resolveSectionTracks(WidgetRef ref, List<String> ids) {
|
||||||
|
final out = <TrackRef>[];
|
||||||
|
for (final i in ids) {
|
||||||
|
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||||
|
if (v != null) out.add(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact-track placeholder. 56dp cover + two text lines, matched to
|
||||||
|
/// CompactTrackCard so swapping in the real card doesn't shift the
|
||||||
|
/// row's height.
|
||||||
|
class _CompactTrackSkeleton extends StatelessWidget {
|
||||||
|
const _CompactTrackSkeleton({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Container(
|
||||||
|
width: 240,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.slate,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(width: 120, height: 12, color: fs.slate),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(width: 80, height: 10, color: fs.slate),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sections ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PlaylistsSection extends StatelessWidget {
|
||||||
|
const _PlaylistsSection({
|
||||||
|
required this.playlists,
|
||||||
|
required this.status,
|
||||||
|
required this.offline,
|
||||||
|
});
|
||||||
|
final List<Playlist> playlists;
|
||||||
|
final SystemPlaylistsStatus status;
|
||||||
|
final bool offline;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final items = _buildPlaylistsRow(playlists, status);
|
||||||
|
final children = <Widget>[
|
||||||
|
// Offline: surface the cache-backed pools where the (now
|
||||||
|
// play-disabled) system playlists sit, so there's something
|
||||||
|
// to play. #427 S4b.
|
||||||
|
if (offline) ...const [
|
||||||
|
_OfflinePoolCard(
|
||||||
|
label: 'Recently played',
|
||||||
|
icon: LucideIcons.history,
|
||||||
|
kind: _OfflinePoolKind.recentlyPlayed,
|
||||||
|
),
|
||||||
|
_OfflinePoolCard(
|
||||||
|
label: 'Liked',
|
||||||
|
icon: LucideIcons.heart,
|
||||||
|
kind: _OfflinePoolKind.liked,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
for (final item in items)
|
||||||
|
if (item is _RealPlaylist)
|
||||||
|
PlaylistCard(playlist: item.playlist)
|
||||||
|
else
|
||||||
|
PlaylistPlaceholderCard(
|
||||||
|
label: (item as _PlaceholderPlaylist).label,
|
||||||
|
variant: item.variant,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return HorizontalScrollRow(
|
||||||
|
title: 'Playlists',
|
||||||
|
height: 220,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _OfflinePoolKind { recentlyPlayed, liked }
|
||||||
|
|
||||||
|
/// Home tile for an offline cache-backed pool. Tapping shuffles +
|
||||||
|
/// plays that pool from the local cache. Sized to match
|
||||||
|
/// PlaylistCard so the row stays visually consistent.
|
||||||
|
class _OfflinePoolCard extends ConsumerWidget {
|
||||||
|
const _OfflinePoolCard({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.kind,
|
||||||
|
});
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final _OfflinePoolKind kind;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return SizedBox(
|
||||||
|
width: 176,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final src = ref.read(shuffleSourceProvider);
|
||||||
|
final refs = await switch (kind) {
|
||||||
|
_OfflinePoolKind.recentlyPlayed => src.recentlyPlayed(),
|
||||||
|
_OfflinePoolKind.liked => src.liked(),
|
||||||
|
};
|
||||||
|
if (refs.isEmpty) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('No cached $label tracks yet')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(refs, shuffle: true);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 144,
|
||||||
|
height: 144,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.slate,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: fs.accent, size: 56),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
'Offline',
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
||||||
|
out.add(forYou != null
|
||||||
|
? _RealPlaylist(forYou)
|
||||||
|
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
||||||
|
|
||||||
|
final discover = findFirst((p) => p.systemVariant == 'discover');
|
||||||
|
out.add(discover != null
|
||||||
|
? _RealPlaylist(discover)
|
||||||
|
: _PlaceholderPlaylist('Discover', _variantFor('discover', status)));
|
||||||
|
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ids});
|
||||||
|
final List<String> ids;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ids.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(ids, 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((id) => _AlbumTile(id: id)).toList(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RediscoverSection extends StatelessWidget {
|
||||||
|
const _RediscoverSection({required this.albumIds, required this.artistIds});
|
||||||
|
final List<String> albumIds;
|
||||||
|
final List<String> artistIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (albumIds.isEmpty && artistIds.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 (albumIds.isNotEmpty)
|
||||||
|
HorizontalScrollRow(
|
||||||
|
title: 'Rediscover',
|
||||||
|
children: albumIds.map((id) => _AlbumTile(id: id)).toList(),
|
||||||
|
),
|
||||||
|
if (artistIds.isNotEmpty)
|
||||||
|
HorizontalScrollRow(
|
||||||
|
title: albumIds.isEmpty ? 'Rediscover' : '',
|
||||||
|
height: 168,
|
||||||
|
children: artistIds.map((id) => _ArtistTile(id: id)).toList(),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MostPlayedSection extends StatelessWidget {
|
||||||
|
const _MostPlayedSection({required this.ids});
|
||||||
|
final List<String> ids;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return const _EmptySection(
|
||||||
|
title: 'Most played',
|
||||||
|
message: 'No plays to draw from. Listen to something.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final controller = ScrollController();
|
||||||
|
final rows = _chunk(ids, 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 (final id in rows[i]) _TrackTile(id: id, sectionIds: ids),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LastPlayedSection extends StatelessWidget {
|
||||||
|
const _LastPlayedSection({required this.ids});
|
||||||
|
final List<String> ids;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return const _EmptySection(
|
||||||
|
title: 'Last played',
|
||||||
|
message: 'No recent plays.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return HorizontalScrollRow(
|
||||||
|
title: 'Last played',
|
||||||
|
height: 168,
|
||||||
|
children: ids.map((id) => _ArtistTile(id: id)).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 — shown only between mount and the first
|
||||||
|
/// homeIndexProvider emission (typically a single drift query + small
|
||||||
|
/// REST round-trip). Each section then renders its own per-tile
|
||||||
|
/// skeletons internally, so this widget's role is just the very first
|
||||||
|
/// pre-discovery frame.
|
||||||
|
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: (_, __) => SkeletonAlbumTile(width: cardWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,12 +1,21 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/client.dart';
|
import '../api/client.dart';
|
||||||
import '../api/endpoints/library.dart';
|
import '../api/endpoints/library.dart';
|
||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
|
import '../cache/adapters.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/cache_first.dart';
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../cache/db.dart';
|
||||||
import '../models/album.dart';
|
import '../models/album.dart';
|
||||||
import '../models/artist.dart';
|
import '../models/artist.dart';
|
||||||
import '../models/home_data.dart';
|
import '../models/home_index.dart';
|
||||||
import '../models/track.dart';
|
import '../models/track.dart';
|
||||||
|
|
||||||
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
||||||
@@ -33,28 +42,366 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
|||||||
return LibraryApi(await ref.watch(dioProvider.future));
|
return LibraryApi(await ref.watch(dioProvider.future));
|
||||||
});
|
});
|
||||||
|
|
||||||
final homeProvider = FutureProvider<HomeData>((ref) async {
|
/// Drift-first per-item home index. Reads from cached_home_index
|
||||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
/// (populated by /api/home/index discovery) and yields a HomeIndex
|
||||||
|
/// the screen consumes to lay out section→tile slots. Each tile is
|
||||||
|
/// rendered by a per-entity tile provider that hydrates itself.
|
||||||
|
///
|
||||||
|
/// Section keys mirror the server's response shape so the encode /
|
||||||
|
/// decode round-trip is straightforward — the table stores
|
||||||
|
/// (section, position, entityType, entityId), and toResult reassembles
|
||||||
|
/// the parallel ID lists.
|
||||||
|
final homeIndexProvider = StreamProvider<HomeIndex>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedHomeIndex)
|
||||||
|
..orderBy([
|
||||||
|
(t) => drift.OrderingTerm.asc(t.section),
|
||||||
|
(t) => drift.OrderingTerm.asc(t.position),
|
||||||
|
]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedHomeIndexData, HomeIndex>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getHomeIndex();
|
||||||
|
// Full replace in a transaction so the watch() sees exactly one
|
||||||
|
// post-fetch emission with the merged state. Section ordering is
|
||||||
|
// re-asserted on read (orderBy above) so the write order doesn't
|
||||||
|
// matter — letting us batch flat instead of section-by-section.
|
||||||
|
await db.transaction(() async {
|
||||||
|
await db.delete(db.cachedHomeIndex).go();
|
||||||
|
await db.batch((b) {
|
||||||
|
void rows(String section, String entityType, List<String> ids) {
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
b.insert(
|
||||||
|
db.cachedHomeIndex,
|
||||||
|
CachedHomeIndexCompanion.insert(
|
||||||
|
section: section,
|
||||||
|
position: i,
|
||||||
|
entityType: entityType,
|
||||||
|
entityId: ids[i],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums);
|
||||||
|
rows('rediscover_albums', 'album', fresh.rediscoverAlbums);
|
||||||
|
rows('rediscover_artists', 'artist', fresh.rediscoverArtists);
|
||||||
|
rows('most_played_tracks', 'track', fresh.mostPlayedTracks);
|
||||||
|
rows('last_played_artists', 'artist', fresh.lastPlayedArtists);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) {
|
||||||
|
final recentlyAddedAlbums = <String>[];
|
||||||
|
final rediscoverAlbums = <String>[];
|
||||||
|
final rediscoverArtists = <String>[];
|
||||||
|
final mostPlayedTracks = <String>[];
|
||||||
|
final lastPlayedArtists = <String>[];
|
||||||
|
for (final r in rows) {
|
||||||
|
switch (r.section) {
|
||||||
|
case 'recently_added_albums':
|
||||||
|
recentlyAddedAlbums.add(r.entityId);
|
||||||
|
case 'rediscover_albums':
|
||||||
|
rediscoverAlbums.add(r.entityId);
|
||||||
|
case 'rediscover_artists':
|
||||||
|
rediscoverArtists.add(r.entityId);
|
||||||
|
case 'most_played_tracks':
|
||||||
|
mostPlayedTracks.add(r.entityId);
|
||||||
|
case 'last_played_artists':
|
||||||
|
lastPlayedArtists.add(r.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HomeIndex(
|
||||||
|
recentlyAddedAlbums: recentlyAddedAlbums,
|
||||||
|
rediscoverAlbums: rediscoverAlbums,
|
||||||
|
rediscoverArtists: rediscoverArtists,
|
||||||
|
mostPlayedTracks: mostPlayedTracks,
|
||||||
|
lastPlayedArtists: lastPlayedArtists,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: cached layout shows instantly; fresh /api/home/index lands
|
||||||
|
// in the background and tiles re-resolve as the table mutates.
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'homeIndex',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
||||||
|
/// when empty + online, fetches via REST + populates drift, which
|
||||||
|
/// re-emits via watch().
|
||||||
final artistProvider =
|
final artistProvider =
|
||||||
FutureProvider.family<ArtistRef, String>((ref, id) async {
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
|
final db = ref.watch(appDbProvider);
|
||||||
|
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
||||||
|
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
||||||
|
// first album.
|
||||||
|
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
||||||
|
.join([
|
||||||
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
return cacheFirst<drift.TypedResult, ArtistRef>(
|
||||||
|
driftStream: query.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) {
|
||||||
|
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
||||||
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
},
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// No alwaysRefresh: artist rows don't change frequently, and the
|
||||||
|
// metadata prefetcher creates many subscriptions in parallel —
|
||||||
|
// each silently re-fetching once would saturate the request
|
||||||
|
// pipeline behind the user's actual playback request.
|
||||||
|
tag: 'artist($id)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final artistAlbumsProvider =
|
final artistAlbumsProvider =
|
||||||
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
|
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
|
final db = ref.watch(appDbProvider);
|
||||||
|
// Join cached_albums + cached_artists to fill artist_name on each row.
|
||||||
|
final query = db.select(db.cachedAlbums).join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
])..where(db.cachedAlbums.artistId.equals(artistId));
|
||||||
|
|
||||||
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getArtistAlbums(artistId);
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.map((r) {
|
||||||
|
final album = r.readTable(db.cachedAlbums);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
return album.toRef(artistName: artist?.name ?? '');
|
||||||
|
}).toList(),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: re-fetch the full album list on every visit. Without this,
|
||||||
|
// a previously-incomplete drift cache (e.g. user had only opened
|
||||||
|
// one album by this artist, so cachedAlbums had just that row)
|
||||||
|
// would render forever as a partial list. The prefetcher only
|
||||||
|
// warms artistProvider (single row), so this provider isn't
|
||||||
|
// mass-instantiated and the storm risk that motivated dropping
|
||||||
|
// alwaysRefresh elsewhere doesn't apply here.
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'artistAlbums($artistId)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final artistTracksProvider =
|
final artistTracksProvider =
|
||||||
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
|
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
||||||
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = db.select(db.cachedTracks).join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||||
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
||||||
|
])..where(db.cachedTracks.artistId.equals(artistId));
|
||||||
|
|
||||||
|
return cacheFirst<drift.TypedResult, List<TrackRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api.getArtistTracks(artistId);
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.map((r) {
|
||||||
|
final track = r.readTable(db.cachedTracks);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
final album = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
return track.toRef(
|
||||||
|
artistName: artist?.name ?? '',
|
||||||
|
albumTitle: album?.title ?? '',
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
tag: 'artistTracks($artistId)',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final albumProvider =
|
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
||||||
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
|
/// for reactive updates over both rows.
|
||||||
(ref, id) async {
|
final albumProvider = StreamProvider.family<
|
||||||
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
|
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
|
||||||
},
|
final db = ref.watch(appDbProvider);
|
||||||
);
|
|
||||||
|
final albumQuery = (db.select(db.cachedAlbums)
|
||||||
|
..where((t) => t.id.equals(albumId)))
|
||||||
|
.join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final tracksQuery = (db.select(db.cachedTracks)
|
||||||
|
..where((t) => t.albumId.equals(albumId))
|
||||||
|
..orderBy([
|
||||||
|
(t) => drift.OrderingTerm.asc(t.discNumber),
|
||||||
|
(t) => drift.OrderingTerm.asc(t.trackNumber),
|
||||||
|
]))
|
||||||
|
.join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Once-per-subscription guard so we don't re-fetch in a loop if the
|
||||||
|
// server genuinely returns zero tracks (or if the fetch fails).
|
||||||
|
var fetchAttempted = false;
|
||||||
|
// (revalidated flag removed; see SWR note below the yield.)
|
||||||
|
|
||||||
|
Future<bool> isOnline() async {
|
||||||
|
try {
|
||||||
|
return await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold-cache fetch: pulls the album + its tracks + any unique
|
||||||
|
// artists referenced and writes all three tables in one batch.
|
||||||
|
// Returns true on success (drift watch will re-emit), false on
|
||||||
|
// failure so the caller can yield an empty result.
|
||||||
|
Future<bool> fetchAndPopulate() async {
|
||||||
|
try {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final fresh = await api
|
||||||
|
.getAlbum(albumId)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
// Collect every artist mentioned by the album + its tracks so
|
||||||
|
// the JOINs that drive both the album header and the track rows
|
||||||
|
// have something to bind to. Without this, drift returns null
|
||||||
|
// for the artist row and artistName surfaces empty — visible
|
||||||
|
// as a missing artist line in the mini player and row list.
|
||||||
|
final artists = <String, ArtistRef>{};
|
||||||
|
if (fresh.album.artistId.isNotEmpty &&
|
||||||
|
fresh.album.artistName.isNotEmpty) {
|
||||||
|
artists[fresh.album.artistId] = ArtistRef(
|
||||||
|
id: fresh.album.artistId,
|
||||||
|
name: fresh.album.artistName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final t in fresh.tracks) {
|
||||||
|
if (t.artistId.isNotEmpty &&
|
||||||
|
t.artistName.isNotEmpty &&
|
||||||
|
!artists.containsKey(t.artistId)) {
|
||||||
|
artists[t.artistId] = ArtistRef(
|
||||||
|
id: t.artistId,
|
||||||
|
name: t.artistName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.batch((b) {
|
||||||
|
if (artists.isNotEmpty) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
|
||||||
|
}
|
||||||
|
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
|
||||||
|
b.insertAllOnConflictUpdate(db.cachedTracks,
|
||||||
|
fresh.tracks.map((t) => t.toDrift()).toList());
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await for (final albumRows in albumQuery.watch()) {
|
||||||
|
// Case 1: no album row at all → cold-fetch.
|
||||||
|
if (albumRows.isEmpty) {
|
||||||
|
if (fetchAttempted) {
|
||||||
|
// Already tried and got nothing back.
|
||||||
|
yield (
|
||||||
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
|
tracks: const <TrackRef>[],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fetchAttempted = true;
|
||||||
|
if (!await isOnline()) {
|
||||||
|
yield (
|
||||||
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
|
tracks: const <TrackRef>[],
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final ok = await fetchAndPopulate();
|
||||||
|
if (!ok) {
|
||||||
|
yield (
|
||||||
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||||
|
tracks: const <TrackRef>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// On success, drift watch re-emits with rows; loop continues.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumRow = albumRows.first;
|
||||||
|
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
||||||
|
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final trackRows = await tracksQuery.get();
|
||||||
|
|
||||||
|
// Case 2: album row exists but no tracks. This happens when an
|
||||||
|
// upstream provider (artistAlbumsProvider, sync, etc.) populated
|
||||||
|
// album rows without their track lists. Trigger the same
|
||||||
|
// fetchAndPopulate so the album becomes complete; drift watch
|
||||||
|
// re-emits and we land in the populated branch on the next pass.
|
||||||
|
if (trackRows.isEmpty && !fetchAttempted) {
|
||||||
|
fetchAttempted = true;
|
||||||
|
if (await isOnline()) {
|
||||||
|
final ok = await fetchAndPopulate();
|
||||||
|
if (ok) continue; // wait for watch re-emit
|
||||||
|
}
|
||||||
|
// Fall through and yield with the album + empty tracks if the
|
||||||
|
// fetch failed or we're offline.
|
||||||
|
}
|
||||||
|
|
||||||
|
final tracks = trackRows.map((r) {
|
||||||
|
final track = r.readTable(db.cachedTracks);
|
||||||
|
final artist = r.readTableOrNull(db.cachedArtists);
|
||||||
|
return track.toRef(
|
||||||
|
artistName: artist?.name ?? '',
|
||||||
|
albumTitle: album.title,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Note: NO SWR here on purpose. Prior code kicked a background
|
||||||
|
// refresh on every first cache hit, which combined with the
|
||||||
|
// metadata prefetcher meant every prewarmed album id triggered
|
||||||
|
// an extra fetch even when drift was already populated. Tracks
|
||||||
|
// and album metadata don't change on the same timescale as
|
||||||
|
// playlists; a stale read is fine until the user invalidates
|
||||||
|
// (pull-to-refresh) or the album is genuinely re-fetched.
|
||||||
|
|
||||||
|
yield (album: album, tracks: tracks);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,936 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../api/endpoints/library_lists.dart';
|
||||||
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../api/endpoints/me.dart';
|
||||||
|
import '../auth/auth_provider.dart' show authControllerProvider;
|
||||||
|
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 '../cache/metadata_prefetcher.dart';
|
||||||
|
import '../cache/shuffle_source.dart';
|
||||||
|
import '../cache/tile_providers.dart';
|
||||||
|
import '../library/library_providers.dart' show dioProvider;
|
||||||
|
import '../models/album.dart';
|
||||||
|
import '../models/artist.dart';
|
||||||
|
import '../models/history_event.dart';
|
||||||
|
import '../models/quarantine_mine.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import '../player/player_provider.dart';
|
||||||
|
import '../quarantine/quarantine_provider.dart';
|
||||||
|
import '../shared/live_events_provider.dart';
|
||||||
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/skeletons.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
|
import '../theme/theme_extension.dart';
|
||||||
|
import 'widgets/album_card.dart';
|
||||||
|
import 'widgets/artist_card.dart';
|
||||||
|
import 'widgets/track_row.dart';
|
||||||
|
|
||||||
|
// Providers scoped to this screen. Each tab gets its own first page.
|
||||||
|
// Infinite scroll is a future enhancement; for v1 phone testing the
|
||||||
|
// first 50 rows per tab is enough.
|
||||||
|
|
||||||
|
final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
|
||||||
|
return LibraryListsApi(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
|
return LikesApi(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
|
|
||||||
|
final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||||
|
return MeApi(await ref.watch(dioProvider.future));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Drift-first all-artists list. Reads cached_artists ordered by
|
||||||
|
/// sortName; GridView.builder takes care of lazy widget construction
|
||||||
|
/// so loading the full set up front is fine for typical library sizes.
|
||||||
|
/// SWR refresh on every subscription hits /api/artists with a generous
|
||||||
|
/// limit so newly-scanned-but-not-yet-synced rows land soon after.
|
||||||
|
///
|
||||||
|
/// The old AsyncNotifier+loadMore infinite-scroll path went away: the
|
||||||
|
/// previous "fetch one page at a time as you scroll" felt like the
|
||||||
|
/// rest of the app was buffering when this tab was the slow surface,
|
||||||
|
/// and SyncController already populates the full set of artist rows
|
||||||
|
/// via /api/library/sync.
|
||||||
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
// LEFT JOIN cached_albums so each artist row carries a representative
|
||||||
|
// album id for cover-URL reconstruction. Sorted by artist sortName
|
||||||
|
// then album sortTitle: the first row per artist gets the
|
||||||
|
// alphabetically-first album. Dedup happens in toResult.
|
||||||
|
final query = db.select(db.cachedArtists).join([
|
||||||
|
drift.leftOuterJoin(db.cachedAlbums,
|
||||||
|
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
|
||||||
|
])
|
||||||
|
..orderBy([
|
||||||
|
drift.OrderingTerm.asc(db.cachedArtists.sortName),
|
||||||
|
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
|
||||||
|
]);
|
||||||
|
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
// Cold-cache fallback: sync should have populated drift already,
|
||||||
|
// but a fresh install + first Library visit before sync completes
|
||||||
|
// will be empty. Fetch a generous chunk via /api/artists and
|
||||||
|
// persist; subsequent SWR refreshes keep things current.
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final fresh = await api.listArtists(limit: 1000, offset: 0);
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedArtists,
|
||||||
|
fresh.items.map((a) => a.toDrift()).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) {
|
||||||
|
// The join multiplies rows by album count; dedup by artist id
|
||||||
|
// keeping the first occurrence so we get one ArtistRef per
|
||||||
|
// artist with the alphabetically-first album's id for the
|
||||||
|
// cover-URL projection. Artists with no albums in drift yet
|
||||||
|
// come through as a single row with null cachedAlbums and an
|
||||||
|
// empty coverUrl — UI falls back to the placeholder icon.
|
||||||
|
final seen = <String>{};
|
||||||
|
final out = <ArtistRef>[];
|
||||||
|
for (final r in rows) {
|
||||||
|
final a = r.readTable(db.cachedArtists);
|
||||||
|
if (!seen.add(a.id)) continue;
|
||||||
|
final album = r.readTableOrNull(db.cachedAlbums);
|
||||||
|
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'libraryArtists',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Drift-first all-albums list. Joins cached_albums with cached_artists
|
||||||
|
/// for the artistName field. Same cold-cache fallback + SWR refresh
|
||||||
|
/// pattern as libraryArtistsProvider.
|
||||||
|
final _libraryAlbumsProvider = StreamProvider<List<AlbumRef>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = db.select(db.cachedAlbums).join([
|
||||||
|
drift.leftOuterJoin(db.cachedArtists,
|
||||||
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
||||||
|
])
|
||||||
|
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
|
||||||
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
||||||
|
driftStream: query.watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(_libraryListsApiProvider.future);
|
||||||
|
final fresh = await api.listAlbums(limit: 1000, offset: 0);
|
||||||
|
await db.batch((b) {
|
||||||
|
b.insertAllOnConflictUpdate(
|
||||||
|
db.cachedAlbums,
|
||||||
|
fresh.items.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: 'libraryAlbums',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drift-first History tab. Mirrors homeProvider's pattern: store the
|
||||||
|
// last /api/me/history page as JSON in a single-row drift table, yield
|
||||||
|
// it immediately on subscribe (so the tab paints from disk on cold
|
||||||
|
// open), then SWR-refresh in the background. Also gives basic offline
|
||||||
|
// scrollback — the last fetched page survives connectivity loss.
|
||||||
|
//
|
||||||
|
// JSON blob (vs columnar) because the page is small, always read whole,
|
||||||
|
// and the HistoryPage.fromJson constructor already accepts the wire
|
||||||
|
// shape — no schema-evolution pain when server-side fields change.
|
||||||
|
final _historyProvider = StreamProvider<HistoryPage>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
return cacheFirst<CachedHistorySnapshotData, HistoryPage>(
|
||||||
|
driftStream: db.select(db.cachedHistorySnapshot).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(_meApiProvider.future);
|
||||||
|
final fresh = await api.history();
|
||||||
|
await db.into(db.cachedHistorySnapshot).insertOnConflictUpdate(
|
||||||
|
CachedHistorySnapshotCompanion.insert(
|
||||||
|
json: _encodeHistoryPage(fresh),
|
||||||
|
updatedAt: drift.Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toResult: (rows) => rows.isEmpty
|
||||||
|
? const HistoryPage(events: [], hasMore: false)
|
||||||
|
: HistoryPage.fromJson(
|
||||||
|
jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
// SWR: yield cache instantly, then refresh in the background so the
|
||||||
|
// tab reflects the freshest plays. Matches homeProvider behavior.
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'history',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Encodes HistoryPage back to the wire-format JSON shape
|
||||||
|
/// /api/me/history emits, so HistoryPage.fromJson can round-trip
|
||||||
|
/// through the drift cache.
|
||||||
|
String _encodeHistoryPage(HistoryPage h) => jsonEncode({
|
||||||
|
'events': h.events
|
||||||
|
.map((e) => {
|
||||||
|
'id': e.id,
|
||||||
|
'played_at': e.playedAt,
|
||||||
|
'track': {
|
||||||
|
'id': e.track.id,
|
||||||
|
'title': e.track.title,
|
||||||
|
'album_id': e.track.albumId,
|
||||||
|
'album_title': e.track.albumTitle,
|
||||||
|
'artist_id': e.track.artistId,
|
||||||
|
'artist_name': e.track.artistName,
|
||||||
|
'track_number': e.track.trackNumber,
|
||||||
|
'disc_number': e.track.discNumber,
|
||||||
|
'duration_sec': e.track.durationSec,
|
||||||
|
'stream_url': e.track.streamUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
'has_more': h.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-item Liked tabs (Slice E of the per-item rendering pass).
|
||||||
|
// Each provider yields just the ordered list of entity IDs; the UI
|
||||||
|
// then renders per-tile widgets that hydrate each entity individually
|
||||||
|
// via albumTileProvider / artistTileProvider / trackTileProvider.
|
||||||
|
//
|
||||||
|
// Reads come from cached_likes (sync- and optimistic-write-populated),
|
||||||
|
// projected with ORDER BY likedAt DESC. fetchAndPopulate hits the
|
||||||
|
// cheap /api/likes/ids endpoint — the bulk /api/likes/* endpoints
|
||||||
|
// that returned fully denormalized entities are no longer needed for
|
||||||
|
// this path since tile providers handle entity hydration themselves.
|
||||||
|
//
|
||||||
|
// likedAt ordering note: cached_likes.likedAt is whatever drift
|
||||||
|
// assigned via currentDateAndTime when the row was first inserted
|
||||||
|
// (either by sync or LikesController). insertOrIgnore on subsequent
|
||||||
|
// fetches preserves the existing likedAt so ordering stays stable.
|
||||||
|
// Approximate but acceptable — matches prior Slice 3 behavior.
|
||||||
|
|
||||||
|
List<String> _idsForEntity(List<CachedLike> rows) =>
|
||||||
|
rows.map((r) => r.entityId).toList(growable: false);
|
||||||
|
|
||||||
|
final _likedTrackIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('track'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedTrackIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likedAlbumIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('album'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedAlbumIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final _likedArtistIdsProvider = StreamProvider<List<String>>((ref) {
|
||||||
|
final db = ref.watch(appDbProvider);
|
||||||
|
final query = (db.select(db.cachedLikes)
|
||||||
|
..where((t) => t.entityType.equals('artist'))
|
||||||
|
..orderBy([(t) => drift.OrderingTerm.desc(t.likedAt)]))
|
||||||
|
.watch();
|
||||||
|
return cacheFirst<CachedLike, List<String>>(
|
||||||
|
driftStream: query,
|
||||||
|
fetchAndPopulate: () => _populateLikeIds(ref),
|
||||||
|
toResult: _idsForEntity,
|
||||||
|
isOnline: () async => (await ref
|
||||||
|
.read(connectivityProvider.future)
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
alwaysRefresh: true,
|
||||||
|
tag: 'likedArtistIds',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shared cold-cache populator: hits /api/likes/ids once and writes
|
||||||
|
/// rows for all three entity types via insertOrIgnore. The three
|
||||||
|
/// providers above all trigger this on their first empty-drift
|
||||||
|
/// emission; the dedup in cacheFirst's revalidate state plus drift's
|
||||||
|
/// insertOrIgnore semantics make the multiple-trigger case cheap.
|
||||||
|
Future<void> _populateLikeIds(Ref ref) async {
|
||||||
|
final api = await ref.read(_likesApiProvider.future);
|
||||||
|
final user = ref.read(authControllerProvider).value;
|
||||||
|
if (user == null) return;
|
||||||
|
final fresh = await api.ids();
|
||||||
|
final db = ref.read(appDbProvider);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
||||||
|
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
||||||
|
// the Hidden tab keep one source of truth.
|
||||||
|
|
||||||
|
class LibraryScreen extends ConsumerStatefulWidget {
|
||||||
|
const LibraryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late final TabController _ctrl = TabController(length: 5, vsync: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// Pre-warm every tab's provider on Library mount so swiping
|
||||||
|
// between tabs feels instant rather than each tab paying its
|
||||||
|
// own cold-cache cost on first visit. ref.listen subscribes
|
||||||
|
// without rebuilding LibraryScreen on emit; subscriptions stay
|
||||||
|
// alive for the lifetime of this widget. cacheFirst handles
|
||||||
|
// dedupe of concurrent fetchAndPopulate triggers.
|
||||||
|
ref.listen(_libraryArtistsProvider, (_, __) {});
|
||||||
|
ref.listen(_libraryAlbumsProvider, (_, __) {});
|
||||||
|
ref.listen(_historyProvider, (_, __) {});
|
||||||
|
ref.listen(_likedTrackIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedAlbumIdsProvider, (_, __) {});
|
||||||
|
ref.listen(_likedArtistIdsProvider, (_, __) {});
|
||||||
|
ref.listen(myQuarantineProvider, (_, __) {});
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: fs.obsidian,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
key: const Key('shuffle_all_button'),
|
||||||
|
tooltip: 'Shuffle all',
|
||||||
|
icon: Icon(LucideIcons.shuffle, color: fs.parchment),
|
||||||
|
onPressed: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final refs = await ref.read(shuffleSourceProvider).tracks();
|
||||||
|
if (refs.isEmpty) {
|
||||||
|
messenger.showSnackBar(const SnackBar(
|
||||||
|
content: Text('Nothing to shuffle yet'),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(refs, shuffle: true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const MainAppBarActions(currentRoute: '/library'),
|
||||||
|
],
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _ctrl,
|
||||||
|
isScrollable: true,
|
||||||
|
tabAlignment: TabAlignment.start,
|
||||||
|
labelColor: fs.parchment,
|
||||||
|
unselectedLabelColor: fs.ash,
|
||||||
|
indicatorColor: fs.accent,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Artists'),
|
||||||
|
Tab(text: 'Albums'),
|
||||||
|
Tab(text: 'History'),
|
||||||
|
Tab(text: 'Liked'),
|
||||||
|
Tab(text: 'Hidden'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _ctrl,
|
||||||
|
children: const [
|
||||||
|
_ArtistsTab(),
|
||||||
|
_AlbumsTab(),
|
||||||
|
_HistoryTab(),
|
||||||
|
_LikedTab(),
|
||||||
|
_HiddenTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistsTab extends ConsumerWidget {
|
||||||
|
const _ArtistsTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ref.watch(_libraryArtistsProvider).when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
|
data: (artists) {
|
||||||
|
// Warm details for the first screenful so taps are instant.
|
||||||
|
ref
|
||||||
|
.read(metadataPrefetcherProvider)
|
||||||
|
.warmArtists(artists.map((a) => a.id));
|
||||||
|
return artists.isEmpty
|
||||||
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||||
|
// LayoutBuilder + cell-aware ArtistCard width mirrors
|
||||||
|
// the AlbumsTab pattern so the circular avatar stays
|
||||||
|
// a true circle on narrow grid cells.
|
||||||
|
//
|
||||||
|
// Drift-first: GridView holds the full sorted list;
|
||||||
|
// .builder lazily realizes only visible cells, so
|
||||||
|
// even on large libraries the up-front cost is just
|
||||||
|
// a sort over cached_artists, not N network round
|
||||||
|
// trips.
|
||||||
|
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;
|
||||||
|
// avatar (cellW - 16) + gap (8) + 1 line name (~18)
|
||||||
|
// + slack matched to AlbumsTab's overflow guard.
|
||||||
|
final cellH = (cellW - 16) + 8 + 18 + 8;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
|
gridDelegate:
|
||||||
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisExtent: cellH,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
),
|
||||||
|
itemCount: artists.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final artist = artists[i];
|
||||||
|
return ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
width: cellW,
|
||||||
|
onTap: () => ctx.push('/artists/${artist.id}',
|
||||||
|
extra: artist),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlbumsTab extends ConsumerWidget {
|
||||||
|
const _AlbumsTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ref.watch(_libraryAlbumsProvider).when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
|
data: (albums) {
|
||||||
|
return albums.isEmpty
|
||||||
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||||
|
// Same responsive 3-up grid as the artist detail
|
||||||
|
// album list — LayoutBuilder + AlbumCard sized to the
|
||||||
|
// cell, mainAxisExtent matched to actual card height.
|
||||||
|
// Drift-first; full sorted list, lazy realization via
|
||||||
|
// GridView.builder.
|
||||||
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||||
|
const cols = 3;
|
||||||
|
const sidePad = 8.0;
|
||||||
|
const gap = 8.0;
|
||||||
|
final cellW = (constraints.maxWidth -
|
||||||
|
sidePad * 2 -
|
||||||
|
gap * (cols - 1)) /
|
||||||
|
cols;
|
||||||
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||||
|
// + artist line (~16) + slack. Slack is generous on
|
||||||
|
// purpose — line-height + font scaling variations
|
||||||
|
// would otherwise overflow the cell by a pixel
|
||||||
|
// (logged as a noisy RenderFlex warning).
|
||||||
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.all(sidePad),
|
||||||
|
gridDelegate:
|
||||||
|
SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: cols,
|
||||||
|
mainAxisExtent: cellH,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
),
|
||||||
|
itemCount: albums.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final album = albums[i];
|
||||||
|
return AlbumCard(
|
||||||
|
album: album,
|
||||||
|
width: cellW,
|
||||||
|
titleMaxLines: 2,
|
||||||
|
onTap: () => ctx.push('/albums/${album.id}',
|
||||||
|
extra: album),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryTab extends ConsumerWidget {
|
||||||
|
const _HistoryTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ref.watch(_historyProvider).when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
|
data: (page) => page.events.isEmpty
|
||||||
|
? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.refresh(_historyProvider.future),
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: page.events.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final event = page.events[i];
|
||||||
|
return TrackRow(
|
||||||
|
track: event.track,
|
||||||
|
onTap: () => ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks([event.track]),
|
||||||
|
trailing: Text(
|
||||||
|
_relativeTime(event.playedAt),
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LikedTab extends ConsumerWidget {
|
||||||
|
const _LikedTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// SSE wire-up: any cross-device like / unlike triggers a refresh
|
||||||
|
// of the discovery providers. LikesController handles local
|
||||||
|
// mutations optimistically through the same cached_likes table,
|
||||||
|
// so toggling a like locally re-emits the streams instantly
|
||||||
|
// without needing an invalidate here.
|
||||||
|
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||||
|
final e = next.asData?.value;
|
||||||
|
if (e == null) return;
|
||||||
|
switch (e.kind) {
|
||||||
|
case 'track.liked':
|
||||||
|
case 'track.unliked':
|
||||||
|
case 'album.liked':
|
||||||
|
case 'album.unliked':
|
||||||
|
case 'artist.liked':
|
||||||
|
case 'artist.unliked':
|
||||||
|
ref.invalidate(_likedTrackIdsProvider);
|
||||||
|
ref.invalidate(_likedAlbumIdsProvider);
|
||||||
|
ref.invalidate(_likedArtistIdsProvider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
final tracksA = ref.watch(_likedTrackIdsProvider);
|
||||||
|
final albumsA = ref.watch(_likedAlbumIdsProvider);
|
||||||
|
final artistsA = ref.watch(_likedArtistIdsProvider);
|
||||||
|
|
||||||
|
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final trackIds = tracksA.value;
|
||||||
|
final albumIds = albumsA.value;
|
||||||
|
final artistIds = artistsA.value;
|
||||||
|
if (trackIds == null || albumIds == null || artistIds == null) {
|
||||||
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
||||||
|
}
|
||||||
|
if (trackIds.isEmpty && albumIds.isEmpty && artistIds.isEmpty) {
|
||||||
|
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await Future.wait([
|
||||||
|
ref.refresh(_likedTrackIdsProvider.future),
|
||||||
|
ref.refresh(_likedAlbumIdsProvider.future),
|
||||||
|
ref.refresh(_likedArtistIdsProvider.future),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
child: ListView(children: [
|
||||||
|
if (artistIds.isNotEmpty) ...[
|
||||||
|
_SectionHeader(label: 'Artists', count: artistIds.length),
|
||||||
|
SizedBox(
|
||||||
|
height: 168,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: artistIds.length,
|
||||||
|
itemBuilder: (ctx, i) =>
|
||||||
|
_LikedArtistTile(id: artistIds[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (albumIds.isNotEmpty) ...[
|
||||||
|
_SectionHeader(label: 'Albums', count: albumIds.length),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
itemCount: albumIds.length,
|
||||||
|
itemBuilder: (ctx, i) =>
|
||||||
|
_LikedAlbumTile(id: albumIds[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (trackIds.isNotEmpty) ...[
|
||||||
|
_SectionHeader(label: 'Tracks', count: trackIds.length),
|
||||||
|
...trackIds.map(
|
||||||
|
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 96),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skeleton→content cross-fade duration. Matches home_screen so the
|
||||||
|
/// reveal feel is consistent across surfaces. See _tileRevealDuration
|
||||||
|
/// in home_screen.dart for the rationale.
|
||||||
|
const Duration _likedTileReveal = Duration(milliseconds: 220);
|
||||||
|
|
||||||
|
/// Liked-Artists carousel tile. Skeleton until artistTileProvider
|
||||||
|
/// yields a populated row.
|
||||||
|
class _LikedArtistTile extends ConsumerWidget {
|
||||||
|
const _LikedArtistTile({required this.id});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final artist = ref.watch(artistTileProvider(id)).asData?.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: _likedTileReveal,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
child: artist == null
|
||||||
|
? const SkeletonArtistTile(key: ValueKey('skeleton'))
|
||||||
|
: ArtistCard(
|
||||||
|
key: ValueKey('artist-${artist.id}'),
|
||||||
|
artist: artist,
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/artists/${artist.id}', extra: artist),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liked-Albums carousel tile. Skeleton until albumTileProvider
|
||||||
|
/// yields a populated row.
|
||||||
|
class _LikedAlbumTile extends ConsumerWidget {
|
||||||
|
const _LikedAlbumTile({required this.id});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final album = ref.watch(albumTileProvider(id)).asData?.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: _likedTileReveal,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
child: album == null
|
||||||
|
? const SkeletonAlbumTile(key: ValueKey('skeleton'))
|
||||||
|
: AlbumCard(
|
||||||
|
key: ValueKey('album-${album.id}'),
|
||||||
|
album: album,
|
||||||
|
onTap: () => context.push('/albums/${album.id}', extra: album),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liked-Tracks list row. Skeleton until trackTileProvider yields a
|
||||||
|
/// populated row. Tap plays the section starting at this track,
|
||||||
|
/// using whichever tracks are currently hydrated; still-loading
|
||||||
|
/// tracks are skipped from the play queue and join on next rebuild.
|
||||||
|
class _LikedTrackRow extends ConsumerWidget {
|
||||||
|
const _LikedTrackRow({required this.id, required this.sectionIds});
|
||||||
|
final String id;
|
||||||
|
final List<String> sectionIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final track = ref.watch(trackTileProvider(id)).asData?.value;
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: _likedTileReveal,
|
||||||
|
switchInCurve: Curves.easeOut,
|
||||||
|
child: track == null
|
||||||
|
? const SkeletonTrackRow(key: ValueKey('skeleton'))
|
||||||
|
: TrackRow(
|
||||||
|
key: ValueKey('track-${track.id}'),
|
||||||
|
track: track,
|
||||||
|
onTap: () {
|
||||||
|
final hydrated = <TrackRef>[];
|
||||||
|
for (final i in sectionIds) {
|
||||||
|
final v = ref.read(trackTileProvider(i)).asData?.value;
|
||||||
|
if (v != null) hydrated.add(v);
|
||||||
|
}
|
||||||
|
final start = hydrated.indexWhere((t) => t.id == id);
|
||||||
|
ref.read(playerActionsProvider).playTracks(
|
||||||
|
hydrated,
|
||||||
|
initialIndex: start < 0 ? 0 : start,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HiddenTab extends ConsumerWidget {
|
||||||
|
const _HiddenTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return ref.watch(myQuarantineProvider).when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||||
|
data: (rows) => rows.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: fs.ash),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: rows.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||||
|
itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuarantineTile extends ConsumerWidget {
|
||||||
|
const _QuarantineTile({required this.row});
|
||||||
|
final QuarantineMineRow row;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final reasonLabel = switch (row.reason) {
|
||||||
|
'bad_rip' => 'Bad rip',
|
||||||
|
'wrong_file' => 'Wrong file',
|
||||||
|
'wrong_tags' => 'Wrong tags',
|
||||||
|
'duplicate' => 'Duplicate',
|
||||||
|
_ => 'Other',
|
||||||
|
};
|
||||||
|
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: coverUrl.isEmpty
|
||||||
|
? Container(color: fs.slate)
|
||||||
|
: ServerImage(
|
||||||
|
url: coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(
|
||||||
|
row.trackTitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${row.artistName} · ${row.albumTitle}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.iron,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||||
|
),
|
||||||
|
if (row.createdAt.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(_relativeTime(row.createdAt),
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Unhide',
|
||||||
|
icon: Icon(LucideIcons.archive_restore, color: fs.ash, size: 20),
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
|
||||||
|
} catch (_) {
|
||||||
|
// Optimistic rollback handled by the notifier; surface
|
||||||
|
// the failure only if the user kept the tab open.
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not unhide; try again.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionHeader extends StatelessWidget {
|
||||||
|
const _SectionHeader({required this.label, required this.count});
|
||||||
|
final String label;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
|
||||||
|
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
|
||||||
|
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
|
||||||
|
String _relativeTime(String iso) {
|
||||||
|
final t = DateTime.tryParse(iso);
|
||||||
|
if (t == null) return '';
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(t);
|
||||||
|
if (diff.inMinutes < 60) {
|
||||||
|
final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
|
||||||
|
return '${m}m ago';
|
||||||
|
}
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) {
|
||||||
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
final hh = t.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = t.minute.toString().padLeft(2, '0');
|
||||||
|
return '${days[t.weekday - 1]} $hh:$mm';
|
||||||
|
}
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
|
||||||
|
return '${months[t.month - 1]} ${t.day}, ${t.year}';
|
||||||
|
}
|
||||||
@@ -1,51 +1,112 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import '../library_providers.dart';
|
||||||
|
import 'play_circle_button.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends ConsumerWidget {
|
||||||
const AlbumCard({required this.album, required this.onTap, super.key});
|
const AlbumCard({
|
||||||
|
required this.album,
|
||||||
|
required this.onTap,
|
||||||
|
this.width = 140,
|
||||||
|
this.titleMaxLines = 1,
|
||||||
|
this.showArtist = true,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
final AlbumRef album;
|
final AlbumRef album;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Outer width of the card. Cover is square at width - 16 (8dp
|
||||||
|
/// horizontal padding either side). Default suits horizontal lists;
|
||||||
|
/// grids should pass the cell width so the card scales down.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// Lets callers (e.g. the artist detail grid) allow the title to
|
||||||
|
/// wrap to a second line so it isn't truncated to a single
|
||||||
|
/// ellipsized character at narrower widths.
|
||||||
|
final int titleMaxLines;
|
||||||
|
|
||||||
|
/// Suppress the artist name. Useful on surfaces where the artist is
|
||||||
|
/// already implied by the page header (e.g. artist detail).
|
||||||
|
final bool showArtist;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return GestureDetector(
|
final coverSize = width - 16;
|
||||||
onTap: onTap,
|
return SizedBox(
|
||||||
child: SizedBox(
|
width: width,
|
||||||
width: 140,
|
child: Material(
|
||||||
child: Padding(
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
child: InkWell(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
onTap: onTap,
|
||||||
ClipRRect(
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(6),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Container(
|
child: Column(
|
||||||
width: 124,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
height: 124,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: fs.slate,
|
children: [
|
||||||
child: album.coverUrl.isEmpty
|
// Stack: cover image + overlaid play button at bottom-right.
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
Stack(
|
||||||
: Image.network(album.coverUrl, fit: BoxFit.cover),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
child: PlayCircleButton(
|
||||||
|
onPressed: () => _playAlbum(ref),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the album's tracks via /api/albums/{id} and starts playback
|
||||||
|
/// from the first track. Errors are swallowed by the button's outer
|
||||||
|
/// try/finally; callers don't surface them — failed fetches just keep
|
||||||
|
/// the spinner visible until the button is retapped.
|
||||||
|
Future<void> _playAlbum(WidgetRef ref) async {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final result = await api.getAlbum(album.id);
|
||||||
|
if (result.tracks.isEmpty) return;
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(result.tracks, initialIndex: 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,101 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
import '../library_providers.dart';
|
||||||
|
import 'play_circle_button.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends ConsumerWidget {
|
||||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
const ArtistCard({
|
||||||
|
required this.artist,
|
||||||
|
required this.onTap,
|
||||||
|
this.width = 140,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
final ArtistRef artist;
|
final ArtistRef artist;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Outer width of the card. Avatar is a circle of width-16 (8dp
|
||||||
|
/// horizontal padding either side). Default suits horizontal lists;
|
||||||
|
/// grids should pass the cell width so the avatar shrinks
|
||||||
|
/// proportionally and stays a true circle.
|
||||||
|
final double width;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return GestureDetector(
|
final coverSize = width - 16;
|
||||||
onTap: onTap,
|
return SizedBox(
|
||||||
child: SizedBox(
|
width: width,
|
||||||
width: 140,
|
child: Material(
|
||||||
child: Padding(
|
color: Colors.transparent,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
child: InkWell(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
onTap: onTap,
|
||||||
ClipOval(
|
child: Padding(
|
||||||
child: Container(
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
width: 124,
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
height: 124,
|
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||||
color: fs.slate,
|
// Avatar size derives from the [width] parameter so the
|
||||||
child: artist.coverUrl.isEmpty
|
// Library Artists 3-column grid can pass its cell width
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
// (~109dp on typical phones) and the avatar stays a
|
||||||
: Image.network(artist.coverUrl, fit: BoxFit.cover),
|
// perfect circle. Previous hardcoded 124×124 got squeezed
|
||||||
|
// horizontally in the grid (cell narrower than 124+16
|
||||||
|
// padding) but kept its 124dp height — ClipOval produced
|
||||||
|
// a visible vertical ellipse. Mirrors AlbumCard's width-
|
||||||
|
// parameter convention.
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
color: fs.slate,
|
||||||
|
child: artist.coverUrl.isEmpty
|
||||||
|
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||||
|
fit: BoxFit.cover)
|
||||||
|
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
child: PlayCircleButton(
|
||||||
|
onPressed: () => _playArtistShuffle(ref),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
artist.name,
|
||||||
artist.name,
|
maxLines: 1,
|
||||||
maxLines: 1,
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
),
|
||||||
),
|
]),
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the artist's tracks via /api/artists/{id}/tracks, shuffles
|
||||||
|
/// them (Fisher-Yates, default Random), and plays from index 0.
|
||||||
|
/// Matches the web ArtistCard's `playQueue(shuffle(tracks), 0)`.
|
||||||
|
Future<void> _playArtistShuffle(WidgetRef ref) async {
|
||||||
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
|
final tracks = await api.getArtistTracks(artist.id);
|
||||||
|
if (tracks.isEmpty) return;
|
||||||
|
final shuffled = List.of(tracks);
|
||||||
|
shuffled.shuffle(Random());
|
||||||
|
await ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(shuffled, initialIndex: 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.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(LucideIcons.circle_check_big, size: 14, color: fs.accent),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../models/track.dart';
|
||||||
|
import '../../player/player_provider.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
|
import '../../shared/widgets/track_actions/track_actions_button.dart';
|
||||||
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
/// Small horizontal track cell used by the home Most-played section.
|
||||||
|
/// Mirrors the web CompactTrackCard sizing (~176dp wide, ~56dp tall).
|
||||||
|
/// Tap plays from this track within [sectionTracks] starting at [index].
|
||||||
|
class CompactTrackCard extends ConsumerWidget {
|
||||||
|
const CompactTrackCard({
|
||||||
|
super.key,
|
||||||
|
required this.track,
|
||||||
|
required this.sectionTracks,
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TrackRef track;
|
||||||
|
|
||||||
|
/// All tracks in the home section so play-from-here can queue them
|
||||||
|
/// in order.
|
||||||
|
final List<TrackRef> sectionTracks;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
// Cover URL derives from the track's album. Mirrors web
|
||||||
|
// CompactTrackCard which calls coverUrl(track.album_id).
|
||||||
|
final coverUrl = track.albumId.isNotEmpty
|
||||||
|
? '/api/albums/${track.albumId}/cover'
|
||||||
|
: '';
|
||||||
|
return SizedBox(
|
||||||
|
width: 176,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => ref
|
||||||
|
.read(playerActionsProvider)
|
||||||
|
.playTracks(sectionTracks, initialIndex: index),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: ServerImage(
|
||||||
|
url: coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fallback: Container(color: fs.slate),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
track.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
track.artistName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TrackActionsButton(track: track),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,17 +23,18 @@ class HorizontalScrollRow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Padding(
|
if (title.isNotEmpty)
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
Padding(
|
||||||
child: Text(
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
title,
|
child: Text(
|
||||||
style: TextStyle(
|
title,
|
||||||
fontFamily: fs.display.fontFamily,
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontFamily: fs.display.fontFamily,
|
||||||
color: fs.parchment,
|
fontSize: 18,
|
||||||
|
color: fs.parchment,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: height,
|
height: height,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||||
|
|
||||||
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
|
/// Always-visible 44dp circular play button overlaid on home-screen
|
||||||
|
/// card art (AlbumCard / ArtistCard / PlaylistCard). Mirrors the
|
||||||
|
/// hover-revealed `.play-overlay` on the web cards, but always shown
|
||||||
|
/// because hover is not a real interaction on touch.
|
||||||
|
///
|
||||||
|
/// Manages its own loading state via [_starting] so the caller's tap
|
||||||
|
/// handler can be `Future<void> Function()` without worrying about
|
||||||
|
/// re-entrancy. Disabled state suppresses the tap (used for empty
|
||||||
|
/// playlists).
|
||||||
|
class PlayCircleButton extends StatefulWidget {
|
||||||
|
const PlayCircleButton({
|
||||||
|
required this.onPressed,
|
||||||
|
this.enabled = true,
|
||||||
|
this.size = 44,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Tap handler. Returns a Future so the button can show a spinner
|
||||||
|
/// during the play setup (fetch detail tracks, etc.).
|
||||||
|
final Future<void> Function() onPressed;
|
||||||
|
|
||||||
|
/// When false, the button is rendered semi-transparent and taps are
|
||||||
|
/// ignored. Empty playlists / artists with no tracks should disable.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Outer diameter in logical pixels. 44 is the iOS / Android
|
||||||
|
/// touch-target minimum; matches the design doc.
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayCircleButton> createState() => _PlayCircleButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayCircleButtonState extends State<PlayCircleButton> {
|
||||||
|
bool _starting = false;
|
||||||
|
|
||||||
|
Future<void> _handleTap() async {
|
||||||
|
if (_starting || !widget.enabled) return;
|
||||||
|
setState(() => _starting = true);
|
||||||
|
try {
|
||||||
|
await widget.onPressed();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _starting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
|
final iconSize = widget.size * 0.5;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: widget.enabled ? _handleTap : null,
|
||||||
|
radius: widget.size / 2,
|
||||||
|
containedInkWell: true,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: Container(
|
||||||
|
width: widget.size,
|
||||||
|
height: widget.size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: fs.accent.withValues(alpha: widget.enabled ? 1.0 : 0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x66000000),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: _starting
|
||||||
|
? SizedBox(
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(fs.parchment),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
LucideIcons.play,
|
||||||
|
color: fs.parchment,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +1,99 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../models/track.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 '../../theme/theme_extension.dart';
|
||||||
|
import 'cached_indicator.dart';
|
||||||
|
|
||||||
class TrackRow extends StatelessWidget {
|
class TrackRow extends ConsumerWidget {
|
||||||
const TrackRow({
|
const TrackRow({
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.actions = true,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final TrackRef track;
|
final TrackRef track;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
|
|
||||||
|
/// Render the 3-dot TrackActionsButton at the end of the row. Default
|
||||||
|
/// true; pass false in surfaces that don't want the menu (e.g. when
|
||||||
|
/// showing a static read-only list).
|
||||||
|
final bool actions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
|
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||||
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
|
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
|
||||||
return InkWell(
|
// Watch the currently-playing media item so the row's accent
|
||||||
onTap: onTap,
|
// highlight tracks playback state. Matches _QueueRow's visual
|
||||||
child: Padding(
|
// treatment in queue_screen.dart so the "you are here" cue is
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
// consistent across album / playlist / queue surfaces.
|
||||||
child: Row(children: [
|
final currentId = ref.watch(mediaItemProvider).value?.id;
|
||||||
if (track.trackNumber != null)
|
final isCurrent = currentId != null && currentId == track.id;
|
||||||
SizedBox(
|
return Container(
|
||||||
width: 28,
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: isCurrent ? fs.iron : null,
|
||||||
track.trackNumber.toString(),
|
border: isCurrent
|
||||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
child: Row(children: [
|
||||||
|
if (track.trackNumber != null)
|
||||||
|
SizedBox(
|
||||||
|
width: 22,
|
||||||
|
child: Text(
|
||||||
|
track.trackNumber.toString(),
|
||||||
|
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
track.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrent ? fs.accent : fs.parchment,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight:
|
||||||
|
isCurrent ? FontWeight.w500 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
CachedIndicator(trackId: track.id),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||||
Text(
|
if (trailing != null)
|
||||||
track.title,
|
Padding(
|
||||||
maxLines: 1,
|
padding: const EdgeInsets.only(left: 8),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: trailing!,
|
||||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
|
||||||
),
|
),
|
||||||
Text(
|
if (actions) TrackActionsButton(track: track),
|
||||||
track.artistName,
|
]),
|
||||||
maxLines: 1,
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
|
||||||
if (trailing != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: trailing!,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../shared/widgets/lucide_heart.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'likes_provider.dart';
|
import 'likes_provider.dart';
|
||||||
|
|
||||||
@@ -25,12 +26,12 @@ class LikeButton extends ConsumerWidget {
|
|||||||
orElse: () => false,
|
orElse: () => false,
|
||||||
);
|
);
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(
|
icon: LucideHeart(
|
||||||
liked ? Icons.favorite : Icons.favorite_border,
|
filled: liked,
|
||||||
color: liked ? fs.accent : fs.ash,
|
color: liked ? fs.accent : fs.ash,
|
||||||
size: size,
|
size: size,
|
||||||
),
|
),
|
||||||
onPressed: () => ref.read(likedIdsProvider.notifier).toggle(kind, id),
|
onPressed: () => ref.read(likesControllerProvider).toggle(kind, id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../api/endpoints/likes.dart';
|
import '../api/endpoints/likes.dart';
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||||
|
import '../cache/cache_first.dart';
|
||||||
|
import '../cache/connectivity_provider.dart';
|
||||||
|
import '../cache/db.dart';
|
||||||
|
import '../cache/mutation_queue.dart';
|
||||||
import '../library/library_providers.dart';
|
import '../library/library_providers.dart';
|
||||||
|
|
||||||
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
final likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
||||||
@@ -22,67 +29,146 @@ class LikedIds {
|
|||||||
LikeKind.album => albums.contains(id),
|
LikeKind.album => albums.contains(id),
|
||||||
LikeKind.track => tracks.contains(id),
|
LikeKind.track => tracks.contains(id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const empty = LikedIds(artists: {}, albums: {}, tracks: {});
|
||||||
}
|
}
|
||||||
|
|
||||||
class LikedIdsController extends AsyncNotifier<LikedIds> {
|
/// Drift-first per #357 plan C. Reads from cached_likes (populated by
|
||||||
@override
|
/// SyncController). Reactive — sync writes propagate via watch().
|
||||||
Future<LikedIds> build() async {
|
/// Empty + online triggers REST cold-cache fallback that re-populates.
|
||||||
final api = await ref.watch(likesApiProvider.future);
|
final likedIdsProvider = StreamProvider<LikedIds>((ref) {
|
||||||
final r = await api.ids();
|
final db = ref.watch(appDbProvider);
|
||||||
return LikedIds(artists: r.artists, albums: r.albums, tracks: r.tracks);
|
return cacheFirst<CachedLike, LikedIds>(
|
||||||
}
|
driftStream: db.select(db.cachedLikes).watch(),
|
||||||
|
fetchAndPopulate: () async {
|
||||||
|
final api = await ref.read(likesApiProvider.future);
|
||||||
|
final fresh = await api.ids();
|
||||||
|
// Need a userId for the composite primary key. The auth controller
|
||||||
|
// exposes the current user.
|
||||||
|
final user = ref.read(authControllerProvider).value;
|
||||||
|
if (user == null) return; // not logged in; skip
|
||||||
|
await db.batch((b) {
|
||||||
|
for (final id in fresh.tracks) {
|
||||||
|
b.insert(
|
||||||
|
db.cachedLikes,
|
||||||
|
CachedLikesCompanion.insert(
|
||||||
|
userId: user.id,
|
||||||
|
entityType: 'track',
|
||||||
|
entityId: id,
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final id in fresh.albums) {
|
||||||
|
b.insert(
|
||||||
|
db.cachedLikes,
|
||||||
|
CachedLikesCompanion.insert(
|
||||||
|
userId: user.id,
|
||||||
|
entityType: 'album',
|
||||||
|
entityId: id,
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final id in fresh.artists) {
|
||||||
|
b.insert(
|
||||||
|
db.cachedLikes,
|
||||||
|
CachedLikesCompanion.insert(
|
||||||
|
userId: user.id,
|
||||||
|
entityType: 'artist',
|
||||||
|
entityId: id,
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toResult: (rows) => LikedIds(
|
||||||
|
tracks: rows
|
||||||
|
.where((r) => r.entityType == 'track')
|
||||||
|
.map((r) => r.entityId)
|
||||||
|
.toSet(),
|
||||||
|
albums: rows
|
||||||
|
.where((r) => r.entityType == 'album')
|
||||||
|
.map((r) => r.entityId)
|
||||||
|
.toSet(),
|
||||||
|
artists: rows
|
||||||
|
.where((r) => r.entityType == 'artist')
|
||||||
|
.map((r) => r.entityId)
|
||||||
|
.toSet(),
|
||||||
|
),
|
||||||
|
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Mutation controller. Writes optimistically to drift first (so the
|
||||||
|
/// likedIdsProvider stream re-emits immediately for snappy UI), then to
|
||||||
|
/// REST. Rolls back drift on REST failure.
|
||||||
|
class LikesController {
|
||||||
|
LikesController(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
Future<void> toggle(LikeKind kind, String id) async {
|
Future<void> toggle(LikeKind kind, String id) async {
|
||||||
final api = await ref.read(likesApiProvider.future);
|
final user = _ref.read(authControllerProvider).value;
|
||||||
final current = state.value ??
|
if (user == null) return;
|
||||||
const LikedIds(artists: {}, albums: {}, tracks: {});
|
final db = _ref.read(appDbProvider);
|
||||||
|
final entityType = _entityType(kind);
|
||||||
|
|
||||||
final isLiked = current.has(kind, id);
|
final existing = await (db.select(db.cachedLikes)
|
||||||
final optimistic = _flip(current, kind, id);
|
..where((t) =>
|
||||||
state = AsyncData(optimistic);
|
t.userId.equals(user.id) &
|
||||||
|
t.entityType.equals(entityType) &
|
||||||
|
t.entityId.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
final wasLiked = existing != null;
|
||||||
|
|
||||||
|
// Optimistic mutation — drift watch() re-emits immediately.
|
||||||
|
if (wasLiked) {
|
||||||
|
await (db.delete(db.cachedLikes)
|
||||||
|
..where((t) =>
|
||||||
|
t.userId.equals(user.id) &
|
||||||
|
t.entityType.equals(entityType) &
|
||||||
|
t.entityId.equals(id)))
|
||||||
|
.go();
|
||||||
|
} else {
|
||||||
|
await db.into(db.cachedLikes).insert(
|
||||||
|
CachedLikesCompanion.insert(
|
||||||
|
userId: user.id,
|
||||||
|
entityType: entityType,
|
||||||
|
entityId: id,
|
||||||
|
),
|
||||||
|
mode: drift.InsertMode.insertOrIgnore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLiked) {
|
final api = await _ref.read(likesApiProvider.future);
|
||||||
|
if (wasLiked) {
|
||||||
await api.unlike(kind, id);
|
await api.unlike(kind, id);
|
||||||
} else {
|
} else {
|
||||||
await api.like(kind, id);
|
await api.like(kind, id);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (_) {
|
||||||
state = AsyncData(current);
|
// REST failed (network or HTTP). Don't roll back drift — the
|
||||||
Error.throwWithStackTrace(e, st);
|
// user's intent is to like/unlike, and we want that visible to
|
||||||
|
// them even when offline. Queue the call for replay; the
|
||||||
|
// MutationReplayer will retry on next connectivity transition.
|
||||||
|
// If retries exhaust (5 attempts), the row drops and the next
|
||||||
|
// SyncController.sync brings drift back in line with the
|
||||||
|
// server's authoritative state.
|
||||||
|
await _ref.read(mutationQueueProvider).enqueue(
|
||||||
|
wasLiked ? MutationKinds.likeRemove : MutationKinds.likeAdd,
|
||||||
|
{'kind': entityType, 'id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LikedIds _flip(LikedIds before, LikeKind kind, String id) {
|
String _entityType(LikeKind kind) => switch (kind) {
|
||||||
Set<String> swap(Set<String> s) {
|
LikeKind.artist => 'artist',
|
||||||
final next = {...s};
|
LikeKind.album => 'album',
|
||||||
if (s.contains(id)) {
|
LikeKind.track => 'track',
|
||||||
next.remove(id);
|
};
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (kind) {
|
|
||||||
LikeKind.artist => LikedIds(
|
|
||||||
artists: swap(before.artists),
|
|
||||||
albums: before.albums,
|
|
||||||
tracks: before.tracks,
|
|
||||||
),
|
|
||||||
LikeKind.album => LikedIds(
|
|
||||||
artists: before.artists,
|
|
||||||
albums: swap(before.albums),
|
|
||||||
tracks: before.tracks,
|
|
||||||
),
|
|
||||||
LikeKind.track => LikedIds(
|
|
||||||
artists: before.artists,
|
|
||||||
albums: before.albums,
|
|
||||||
tracks: swap(before.tracks),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final likedIdsProvider =
|
final likesControllerProvider =
|
||||||
AsyncNotifierProvider<LikedIdsController, LikedIds>(LikedIdsController.new);
|
Provider<LikesController>((ref) => LikesController(ref));
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ Future<void> main() async {
|
|||||||
androidNotificationChannelId: 'com.fabledsword.minstrel.audio',
|
androidNotificationChannelId: 'com.fabledsword.minstrel.audio',
|
||||||
androidNotificationChannelName: 'Minstrel playback',
|
androidNotificationChannelName: 'Minstrel playback',
|
||||||
androidNotificationOngoing: true,
|
androidNotificationOngoing: true,
|
||||||
|
// 8a: hand external surfaces (notification / lock screen / Wear)
|
||||||
|
// a downscaled cover instead of full-res album art — smaller
|
||||||
|
// payload, faster paint, lower memory. 300px is ample for those
|
||||||
|
// targets. preloadArtwork warms it so the first paint isn't blank.
|
||||||
|
artDownscaleWidth: 300,
|
||||||
|
artDownscaleHeight: 300,
|
||||||
|
preloadArtwork: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runApp(ProviderScope(
|
runApp(ProviderScope(
|
||||||
|
|||||||
@@ -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,51 @@
|
|||||||
|
/// Mirrors web/src/lib/api/types.ts ArtistSuggestion / SeedContribution —
|
||||||
|
/// one out-of-library artist from GET /api/discover/suggestions. image_url
|
||||||
|
/// is resolved on-demand from Lidarr server-side (may be empty).
|
||||||
|
class SeedContribution {
|
||||||
|
const SeedContribution({required this.name, required this.isLiked});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final bool isLiked;
|
||||||
|
|
||||||
|
factory SeedContribution.fromJson(Map<String, dynamic> j) => SeedContribution(
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
isLiked: j['is_liked'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArtistSuggestion {
|
||||||
|
const ArtistSuggestion({
|
||||||
|
required this.mbid,
|
||||||
|
required this.name,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.attribution,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String mbid;
|
||||||
|
final String name;
|
||||||
|
final String imageUrl;
|
||||||
|
final List<SeedContribution> attribution;
|
||||||
|
|
||||||
|
factory ArtistSuggestion.fromJson(Map<String, dynamic> j) => ArtistSuggestion(
|
||||||
|
mbid: j['mbid'] as String? ?? '',
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
imageUrl: j['image_url'] as String? ?? '',
|
||||||
|
attribution: ((j['attribution'] as List?) ?? const [])
|
||||||
|
.map((e) =>
|
||||||
|
SeedContribution.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Mirrors web SuggestionFeed.attributionText (Oxford comma, max 3).
|
||||||
|
String get attributionText {
|
||||||
|
if (attribution.isEmpty) return '';
|
||||||
|
final phrases = attribution
|
||||||
|
.map((s) => '${s.isLiked ? 'liked' : 'played'} ${s.name}')
|
||||||
|
.toList(growable: false);
|
||||||
|
if (phrases.length == 1) return 'Because you ${phrases[0]}.';
|
||||||
|
if (phrases.length == 2) {
|
||||||
|
return 'Because you ${phrases[0]} and ${phrases[1]}.';
|
||||||
|
}
|
||||||
|
return 'Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'track.dart';
|
||||||
|
|
||||||
|
/// Mirrors web/src/lib/api/history.ts HistoryEvent. Server emits
|
||||||
|
/// `{events, has_more}` rather than the standard `Page<T>` envelope —
|
||||||
|
/// history is timestamp-keyed so total isn't meaningful, only "more
|
||||||
|
/// pages exist below."
|
||||||
|
class HistoryEvent {
|
||||||
|
const HistoryEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.playedAt,
|
||||||
|
required this.track,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
/// RFC3339 timestamp; UI formats relative ("3h ago" / "Tue 14:32" /
|
||||||
|
/// "May 1, 2025") via formatRelative helper.
|
||||||
|
final String playedAt;
|
||||||
|
final TrackRef track;
|
||||||
|
|
||||||
|
factory HistoryEvent.fromJson(Map<String, dynamic> j) => HistoryEvent(
|
||||||
|
id: j['id'] as String? ?? '',
|
||||||
|
playedAt: j['played_at'] as String? ?? '',
|
||||||
|
track: TrackRef.fromJson(
|
||||||
|
(j['track'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryPage {
|
||||||
|
const HistoryPage({required this.events, required this.hasMore});
|
||||||
|
|
||||||
|
final List<HistoryEvent> events;
|
||||||
|
final bool hasMore;
|
||||||
|
|
||||||
|
factory HistoryPage.fromJson(Map<String, dynamic> j) {
|
||||||
|
final raw = (j['events'] as List?) ?? const [];
|
||||||
|
return HistoryPage(
|
||||||
|
events: raw
|
||||||
|
.map((e) => HistoryEvent.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false),
|
||||||
|
hasMore: j['has_more'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/// Mirrors internal/api/types.go HomeIndexPayload. Five flat slices of
|
||||||
|
/// entity ID strings — the per-item rendering variant of HomeData.
|
||||||
|
/// Section name implies entity type; no per-entry type tag is needed.
|
||||||
|
///
|
||||||
|
/// Slices are non-null after fromJson so callers can branch on `length`
|
||||||
|
/// instead of dealing with null sections.
|
||||||
|
class HomeIndex {
|
||||||
|
const HomeIndex({
|
||||||
|
required this.recentlyAddedAlbums,
|
||||||
|
required this.rediscoverAlbums,
|
||||||
|
required this.rediscoverArtists,
|
||||||
|
required this.mostPlayedTracks,
|
||||||
|
required this.lastPlayedArtists,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> recentlyAddedAlbums;
|
||||||
|
final List<String> rediscoverAlbums;
|
||||||
|
final List<String> rediscoverArtists;
|
||||||
|
final List<String> mostPlayedTracks;
|
||||||
|
final List<String> lastPlayedArtists;
|
||||||
|
|
||||||
|
static const empty = HomeIndex(
|
||||||
|
recentlyAddedAlbums: [],
|
||||||
|
rediscoverAlbums: [],
|
||||||
|
rediscoverArtists: [],
|
||||||
|
mostPlayedTracks: [],
|
||||||
|
lastPlayedArtists: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
factory HomeIndex.fromJson(Map<String, dynamic> j) {
|
||||||
|
List<String> ids(String key) =>
|
||||||
|
((j[key] as List?) ?? const []).map((e) => e.toString()).toList();
|
||||||
|
return HomeIndex(
|
||||||
|
recentlyAddedAlbums: ids('recently_added_albums'),
|
||||||
|
rediscoverAlbums: ids('rediscover_albums'),
|
||||||
|
rediscoverArtists: ids('rediscover_artists'),
|
||||||
|
mostPlayedTracks: ids('most_played_tracks'),
|
||||||
|
lastPlayedArtists: ids('last_played_artists'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list
|
||||||
|
/// endpoint wraps these in `{"invites": [...]}`; the create endpoint
|
||||||
|
/// returns a single bare invite. Envelope parsing is done in
|
||||||
|
/// `AdminInvitesApi`, not here.
|
||||||
|
///
|
||||||
|
/// `invitedBy` is the UUID of the inviting admin (not their username);
|
||||||
|
/// the server doesn't currently denormalize it. Likewise `redeemedBy`.
|
||||||
|
/// TTL is hardcoded server-side at 24h, so admins can't customise the
|
||||||
|
/// expiry — only the optional `note`.
|
||||||
|
class Invite {
|
||||||
|
const Invite({
|
||||||
|
required this.token,
|
||||||
|
required this.invitedBy,
|
||||||
|
this.note,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.expiresAt,
|
||||||
|
this.redeemedAt,
|
||||||
|
this.redeemedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String invitedBy;
|
||||||
|
final String? note;
|
||||||
|
final String createdAt;
|
||||||
|
final String expiresAt;
|
||||||
|
final String? redeemedAt;
|
||||||
|
final String? redeemedBy;
|
||||||
|
|
||||||
|
bool get isRedeemed => redeemedAt != null;
|
||||||
|
|
||||||
|
factory Invite.fromJson(Map<String, dynamic> j) => Invite(
|
||||||
|
token: j['token'] as String? ?? '',
|
||||||
|
invitedBy: j['invited_by'] as String? ?? '',
|
||||||
|
note: j['note'] as String?,
|
||||||
|
createdAt: j['created_at'] as String? ?? '',
|
||||||
|
expiresAt: j['expires_at'] as String? ?? '',
|
||||||
|
redeemedAt: j['redeemed_at'] as String?,
|
||||||
|
redeemedBy: j['redeemed_by'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/// Mirrors web/src/lib/api/types.ts LidarrSearchResult — one row in
|
||||||
|
/// `/api/lidarr/search` results. `in_library` and `requested` let the
|
||||||
|
/// UI gray out rows the user can't act on (already imported / already
|
||||||
|
/// awaiting review).
|
||||||
|
class LidarrSearchResult {
|
||||||
|
const LidarrSearchResult({
|
||||||
|
required this.mbid,
|
||||||
|
required this.name,
|
||||||
|
required this.secondaryText,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.artistMbid,
|
||||||
|
required this.albumMbid,
|
||||||
|
required this.inLibrary,
|
||||||
|
required this.requested,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String mbid;
|
||||||
|
final String name;
|
||||||
|
final String secondaryText;
|
||||||
|
final String imageUrl;
|
||||||
|
final String artistMbid;
|
||||||
|
final String albumMbid;
|
||||||
|
final bool inLibrary;
|
||||||
|
final bool requested;
|
||||||
|
|
||||||
|
factory LidarrSearchResult.fromJson(Map<String, dynamic> j) =>
|
||||||
|
LidarrSearchResult(
|
||||||
|
mbid: j['mbid'] as String? ?? '',
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
secondaryText: j['secondary_text'] as String? ?? '',
|
||||||
|
imageUrl: j['image_url'] as String? ?? '',
|
||||||
|
artistMbid: j['artist_mbid'] as String? ?? '',
|
||||||
|
albumMbid: j['album_mbid'] as String? ?? '',
|
||||||
|
inLibrary: j['in_library'] as bool? ?? false,
|
||||||
|
requested: j['requested'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LidarrRequestKind { artist, album, track }
|
||||||
|
|
||||||
|
extension LidarrRequestKindStr on LidarrRequestKind {
|
||||||
|
String get wire => switch (this) {
|
||||||
|
LidarrRequestKind.artist => 'artist',
|
||||||
|
LidarrRequestKind.album => 'album',
|
||||||
|
LidarrRequestKind.track => 'track',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/// Mirrors web/src/lib/api/me.ts MyProfile. The `display_name` and
|
||||||
|
/// `email` are nullable — server returns null when the user hasn't
|
||||||
|
/// set them yet (registration only requires a username).
|
||||||
|
class MyProfile {
|
||||||
|
const MyProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.username,
|
||||||
|
this.displayName,
|
||||||
|
this.email,
|
||||||
|
required this.isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String username;
|
||||||
|
final String? displayName;
|
||||||
|
final String? email;
|
||||||
|
final bool isAdmin;
|
||||||
|
|
||||||
|
factory MyProfile.fromJson(Map<String, dynamic> j) => MyProfile(
|
||||||
|
id: j['id'] as String? ?? '',
|
||||||
|
username: j['username'] as String? ?? '',
|
||||||
|
displayName: j['display_name'] as String?,
|
||||||
|
email: j['email'] as String?,
|
||||||
|
isAdmin: j['is_admin'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors web/src/lib/api/listenbrainz.ts LBStatus.
|
||||||
|
class ListenBrainzStatus {
|
||||||
|
const ListenBrainzStatus({
|
||||||
|
required this.enabled,
|
||||||
|
required this.tokenSet,
|
||||||
|
this.lastScrobbledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
/// True when the user has stored a token (token itself is never read
|
||||||
|
/// back from the server). UI uses this to show "token saved" vs
|
||||||
|
/// "no token" without exposing the value.
|
||||||
|
final bool tokenSet;
|
||||||
|
final String? lastScrobbledAt;
|
||||||
|
|
||||||
|
factory ListenBrainzStatus.fromJson(Map<String, dynamic> j) =>
|
||||||
|
ListenBrainzStatus(
|
||||||
|
enabled: j['enabled'] as bool? ?? false,
|
||||||
|
tokenSet: j['token_set'] as bool? ?? false,
|
||||||
|
lastScrobbledAt: j['last_scrobbled_at'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Mirrors the server's Page[T] envelope used by paged endpoints
|
||||||
|
// (/api/search, /api/library/artists, /api/library/albums,
|
||||||
|
// /api/likes/*). Class is named `Paged<T>` rather than `Page<T>`
|
||||||
|
// to avoid ambiguous imports with Flutter Material's Navigator-2.0
|
||||||
|
// `Page` class.
|
||||||
|
//
|
||||||
|
// Wire shape from internal/api/types.go Page[T]:
|
||||||
|
// { items: T[], total: int, limit: int, offset: int }
|
||||||
|
//
|
||||||
|
// Dart generics can't auto-infer T from JSON, so callers pass an
|
||||||
|
// itemFromJson function alongside the raw map. Parse logic stays in
|
||||||
|
// each entity's own fromJson; this class only handles the envelope.
|
||||||
|
class Paged<T> {
|
||||||
|
const Paged({
|
||||||
|
required this.items,
|
||||||
|
required this.total,
|
||||||
|
required this.limit,
|
||||||
|
required this.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final int total;
|
||||||
|
final int limit;
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
factory Paged.fromJson(
|
||||||
|
Map<String, dynamic> j,
|
||||||
|
T Function(Map<String, dynamic>) itemFromJson,
|
||||||
|
) {
|
||||||
|
final raw = (j['items'] as List?) ?? const [];
|
||||||
|
return Paged<T>(
|
||||||
|
items: raw
|
||||||
|
.map((e) => itemFromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false),
|
||||||
|
total: (j['total'] as num?)?.toInt() ?? 0,
|
||||||
|
limit: (j['limit'] as num?)?.toInt() ?? 0,
|
||||||
|
offset: (j['offset'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for endpoints that always return the first page or
|
||||||
|
/// when the caller just wants the items.
|
||||||
|
bool get hasMore => offset + items.length < total;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Mirrors internal/api/playlists.go Playlist + PlaylistDetail wire shapes.
|
||||||
|
//
|
||||||
|
// Server distinguishes user playlists (created by the caller) from
|
||||||
|
// system playlists (server-generated mixes like for_you / discover).
|
||||||
|
// system_variant is null for user playlists; "for_you" / "discover"
|
||||||
|
// otherwise. Read-only operations work the same; PATCH/POST/DELETE
|
||||||
|
// are gated on user-owned playlists.
|
||||||
|
|
||||||
|
class Playlist {
|
||||||
|
const Playlist({
|
||||||
|
required this.id,
|
||||||
|
required this.userId,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.isPublic,
|
||||||
|
required this.systemVariant,
|
||||||
|
required this.trackCount,
|
||||||
|
required this.coverUrl,
|
||||||
|
required this.ownerUsername,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String userId;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final bool isPublic;
|
||||||
|
/// "for_you" / "discover" / null
|
||||||
|
final String? systemVariant;
|
||||||
|
final int trackCount;
|
||||||
|
/// Server-emitted URL to the cover; empty when no tracks yet.
|
||||||
|
final String coverUrl;
|
||||||
|
/// Display name of the owner — for showing other users' public playlists.
|
||||||
|
final String ownerUsername;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
|
||||||
|
bool get isSystem => systemVariant != null;
|
||||||
|
|
||||||
|
/// Whether this playlist supports the generic by-kind refresh/
|
||||||
|
/// shuffle endpoints (#411 R2) — i.e. a singleton system kind.
|
||||||
|
/// The server exposes a `refreshable` flag for JSON-sourced
|
||||||
|
/// playlists, but the list tiles are drift-cache-sourced (no
|
||||||
|
/// migration just for this), so derive it: every system kind is a
|
||||||
|
/// singleton except songs_like_artist (multi-per-user). This rule
|
||||||
|
/// holds for For-You/Discover and all planned discovery mixes; if
|
||||||
|
/// a future non-singleton kind appears, extend the exclusion.
|
||||||
|
bool get refreshable => isSystem && systemVariant != 'songs_like_artist';
|
||||||
|
|
||||||
|
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
|
||||||
|
id: j['id'] as String,
|
||||||
|
userId: j['user_id'] as String? ?? '',
|
||||||
|
name: j['name'] as String? ?? '',
|
||||||
|
description: j['description'] as String? ?? '',
|
||||||
|
isPublic: j['is_public'] as bool? ?? false,
|
||||||
|
systemVariant: j['system_variant'] as String?,
|
||||||
|
trackCount: (j['track_count'] as num?)?.toInt() ?? 0,
|
||||||
|
coverUrl: j['cover_url'] as String? ?? '',
|
||||||
|
ownerUsername: j['owner_username'] as String? ?? '',
|
||||||
|
createdAt: j['created_at'] as String? ?? '',
|
||||||
|
updatedAt: j['updated_at'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist row as returned in PlaylistDetail.tracks. Each row carries
|
||||||
|
/// the track's display fields directly so the client doesn't need a
|
||||||
|
/// separate /api/tracks/{id} fetch per row.
|
||||||
|
///
|
||||||
|
/// track_id is nullable: when the upstream track has been removed from
|
||||||
|
/// the library, the row remains in the playlist with its title/artist
|
||||||
|
/// preserved but track_id=null and stream_url=null. UI should render
|
||||||
|
/// these greyed-out and unplayable.
|
||||||
|
class PlaylistTrack {
|
||||||
|
const PlaylistTrack({
|
||||||
|
required this.position,
|
||||||
|
required this.trackId,
|
||||||
|
required this.title,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumTitle,
|
||||||
|
required this.artistId,
|
||||||
|
required this.artistName,
|
||||||
|
required this.durationSec,
|
||||||
|
required this.streamUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int position;
|
||||||
|
final String? trackId;
|
||||||
|
final String title;
|
||||||
|
final String? albumId;
|
||||||
|
final String albumTitle;
|
||||||
|
final String? artistId;
|
||||||
|
final String artistName;
|
||||||
|
final int durationSec;
|
||||||
|
final String? streamUrl;
|
||||||
|
|
||||||
|
bool get isAvailable => trackId != null;
|
||||||
|
|
||||||
|
factory PlaylistTrack.fromJson(Map<String, dynamic> j) => PlaylistTrack(
|
||||||
|
position: (j['position'] as num?)?.toInt() ?? 0,
|
||||||
|
trackId: j['track_id'] as String?,
|
||||||
|
title: j['title'] as String? ?? '',
|
||||||
|
albumId: j['album_id'] as String?,
|
||||||
|
albumTitle: j['album_title'] as String? ?? '',
|
||||||
|
artistId: j['artist_id'] as String?,
|
||||||
|
artistName: j['artist_name'] as String? ?? '',
|
||||||
|
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||||
|
streamUrl: j['stream_url'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlaylistDetail {
|
||||||
|
const PlaylistDetail({required this.playlist, required this.tracks});
|
||||||
|
|
||||||
|
final Playlist playlist;
|
||||||
|
final List<PlaylistTrack> tracks;
|
||||||
|
|
||||||
|
factory PlaylistDetail.fromJson(Map<String, dynamic> j) {
|
||||||
|
final tracksRaw = (j['tracks'] as List?) ?? const [];
|
||||||
|
return PlaylistDetail(
|
||||||
|
playlist: Playlist.fromJson(j),
|
||||||
|
tracks: tracksRaw
|
||||||
|
.map((e) => PlaylistTrack.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/// Mirrors web/src/lib/api/types.ts LidarrQuarantineMineRow. One row
|
||||||
|
/// per quarantined track owned by the caller. Server returns a flat
|
||||||
|
/// list (no Page envelope) at /api/quarantine/mine.
|
||||||
|
class QuarantineMineRow {
|
||||||
|
const QuarantineMineRow({
|
||||||
|
required this.trackId,
|
||||||
|
required this.reason,
|
||||||
|
this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.trackTitle,
|
||||||
|
required this.trackDurationMs,
|
||||||
|
required this.albumId,
|
||||||
|
required this.albumTitle,
|
||||||
|
this.albumCoverArtPath,
|
||||||
|
required this.artistId,
|
||||||
|
required this.artistName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String trackId;
|
||||||
|
/// One of: bad_rip / wrong_file / wrong_tags / duplicate / other.
|
||||||
|
final String reason;
|
||||||
|
final String? notes;
|
||||||
|
final String createdAt;
|
||||||
|
final String trackTitle;
|
||||||
|
final int trackDurationMs;
|
||||||
|
final String albumId;
|
||||||
|
final String albumTitle;
|
||||||
|
final String? albumCoverArtPath;
|
||||||
|
final String artistId;
|
||||||
|
final String artistName;
|
||||||
|
|
||||||
|
factory QuarantineMineRow.fromJson(Map<String, dynamic> j) =>
|
||||||
|
QuarantineMineRow(
|
||||||
|
trackId: j['track_id'] as String? ?? '',
|
||||||
|
reason: j['reason'] as String? ?? 'other',
|
||||||
|
notes: j['notes'] as String?,
|
||||||
|
createdAt: j['created_at'] as String? ?? '',
|
||||||
|
trackTitle: j['track_title'] as String? ?? '',
|
||||||
|
trackDurationMs: (j['track_duration_ms'] as num?)?.toInt() ?? 0,
|
||||||
|
albumId: j['album_id'] as String? ?? '',
|
||||||
|
albumTitle: j['album_title'] as String? ?? '',
|
||||||
|
albumCoverArtPath: j['album_cover_art_path'] as String?,
|
||||||
|
artistId: j['artist_id'] as String? ?? '',
|
||||||
|
artistName: j['artist_name'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'album.dart';
|
||||||
|
import 'artist.dart';
|
||||||
|
import 'page.dart';
|
||||||
|
import 'track.dart';
|
||||||
|
|
||||||
|
/// Mirrors internal/api/search.go SearchResponse — three pages keyed by
|
||||||
|
/// facet, each independently paged. The mobile UI usually only walks
|
||||||
|
/// the first page of each facet (limit 20-50); infinite scroll within a
|
||||||
|
/// facet is a future enhancement, not v1.
|
||||||
|
class SearchResponse {
|
||||||
|
const SearchResponse({
|
||||||
|
required this.artists,
|
||||||
|
required this.albums,
|
||||||
|
required this.tracks,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Paged<ArtistRef> artists;
|
||||||
|
final Paged<AlbumRef> albums;
|
||||||
|
final Paged<TrackRef> tracks;
|
||||||
|
|
||||||
|
factory SearchResponse.fromJson(Map<String, dynamic> j) => SearchResponse(
|
||||||
|
artists: Paged.fromJson(
|
||||||
|
(j['artists'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
ArtistRef.fromJson,
|
||||||
|
),
|
||||||
|
albums: Paged.fromJson(
|
||||||
|
(j['albums'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
AlbumRef.fromJson,
|
||||||
|
),
|
||||||
|
tracks: Paged.fromJson(
|
||||||
|
(j['tracks'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||||
|
TrackRef.fromJson,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
artists.items.isEmpty && albums.items.isEmpty && tracks.items.isEmpty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// Mirrors `systemPlaylistsStatusResp` from internal/api/me_system_playlists.go.
|
||||||
|
/// Reflects the caller's most recent system_playlist_runs row, or zero
|
||||||
|
/// values when no row exists yet (the user has never had a build attempted).
|
||||||
|
class SystemPlaylistsStatus {
|
||||||
|
const SystemPlaylistsStatus({
|
||||||
|
required this.inFlight,
|
||||||
|
this.lastRunAt,
|
||||||
|
this.lastError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool inFlight;
|
||||||
|
final String? lastRunAt;
|
||||||
|
final String? lastError;
|
||||||
|
|
||||||
|
factory SystemPlaylistsStatus.empty() =>
|
||||||
|
const SystemPlaylistsStatus(inFlight: false);
|
||||||
|
|
||||||
|
factory SystemPlaylistsStatus.fromJson(Map<String, dynamic> j) =>
|
||||||
|
SystemPlaylistsStatus(
|
||||||
|
inFlight: j['in_flight'] as bool? ?? false,
|
||||||
|
lastRunAt: j['last_run_at'] as String?,
|
||||||
|
lastError: j['last_error'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,4 +40,19 @@ class TrackRef {
|
|||||||
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0,
|
||||||
streamUrl: j['stream_url'] as String? ?? '',
|
streamUrl: j['stream_url'] as String? ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Round-trips through [TrackRef.fromJson] (same server snake_case
|
||||||
|
/// keys). Used to persist the playback queue for resume-on-launch.
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'title': title,
|
||||||
|
'album_id': albumId,
|
||||||
|
'album_title': albumTitle,
|
||||||
|
'artist_id': artistId,
|
||||||
|
'artist_name': artistName,
|
||||||
|
if (trackNumber != null) 'track_number': trackNumber,
|
||||||
|
if (discNumber != null) 'disc_number': discNumber,
|
||||||
|
'duration_sec': durationSec,
|
||||||
|
'stream_url': streamUrl,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Dominant-color extraction from album cover art (#396 item 1).
|
||||||
|
// The full-screen Now Playing screen uses this to paint a top-to-bottom
|
||||||
|
// gradient backdrop that grounds each track in its album's palette.
|
||||||
|
//
|
||||||
|
// Implementation: reuses AlbumCoverCache to get a local file path for
|
||||||
|
// the cover, then runs PaletteGenerator over it. Results are cached
|
||||||
|
// in-memory keyed by album_id so back-to-back plays of the same album
|
||||||
|
// don't repeat the work. Cache is process-lifetime; album-art changes
|
||||||
|
// are rare enough that LRU eviction isn't worth the complexity.
|
||||||
|
//
|
||||||
|
// Returns null on any failure (no cover, empty album_id, palette
|
||||||
|
// extraction returned nothing). Callers fall back to the FabledSword
|
||||||
|
// obsidian color for the gradient when null.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
|
||||||
|
import 'player_provider.dart';
|
||||||
|
|
||||||
|
/// Caches dominant colors keyed by album_id. Process-lifetime; refilled
|
||||||
|
/// on app restart.
|
||||||
|
class AlbumColorCache {
|
||||||
|
AlbumColorCache(this._ref);
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
final Map<String, Color?> _cache = {};
|
||||||
|
|
||||||
|
/// Returns the dominant color for the album's cover, or null if no
|
||||||
|
/// cover is available or extraction fails. Same call for the same
|
||||||
|
/// album_id is cached after the first successful resolution.
|
||||||
|
Future<Color?> getOrExtract(String albumId) async {
|
||||||
|
if (albumId.isEmpty) return null;
|
||||||
|
if (_cache.containsKey(albumId)) return _cache[albumId];
|
||||||
|
final color = await _extract(albumId);
|
||||||
|
_cache[albumId] = color;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous peek. Returns the previously-extracted color if this
|
||||||
|
/// album has been resolved this process; otherwise null. Used by
|
||||||
|
/// the now-playing fast-path swap so a warm cache transitions the
|
||||||
|
/// gradient in lockstep with the audio, instead of awaiting the
|
||||||
|
/// async [getOrExtract] future. A null return means "either no
|
||||||
|
/// extraction yet or extraction returned null" — caller falls back
|
||||||
|
/// to the async path.
|
||||||
|
Color? peekColor(String albumId) => _cache[albumId];
|
||||||
|
|
||||||
|
Future<Color?> _extract(String albumId) async {
|
||||||
|
try {
|
||||||
|
final coverCache = _ref.read(albumCoverCacheProvider);
|
||||||
|
final path = await coverCache.getOrFetch(albumId);
|
||||||
|
if (path == null) return null;
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
|
FileImage(file),
|
||||||
|
// Small target size: palette extraction is CPU-bound and the
|
||||||
|
// gradient only needs a single dominant color, so we don't
|
||||||
|
// need full-resolution sampling.
|
||||||
|
size: const Size(80, 80),
|
||||||
|
maximumColorCount: 8,
|
||||||
|
);
|
||||||
|
// Prefer the explicit dominant color; fall back to the strongest
|
||||||
|
// muted swatch (which tends to read better as a background than
|
||||||
|
// a hot vibrant pick), then any populated swatch.
|
||||||
|
final swatch = palette.dominantColor ??
|
||||||
|
palette.darkMutedColor ??
|
||||||
|
palette.darkVibrantColor ??
|
||||||
|
palette.mutedColor;
|
||||||
|
return swatch?.color;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final albumColorCacheProvider = Provider<AlbumColorCache>(
|
||||||
|
(ref) => AlbumColorCache(ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Family provider: dominant color for the given album_id, or null if
|
||||||
|
/// no cover / extraction failed. The Now Playing screen watches this
|
||||||
|
/// keyed by the current MediaItem's album_id.
|
||||||
|
final albumColorProvider = FutureProvider.family<Color?, String>(
|
||||||
|
(ref, albumId) async {
|
||||||
|
final cache = ref.watch(albumColorCacheProvider);
|
||||||
|
return cache.getOrExtract(albumId);
|
||||||
|
},
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user