Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -72,9 +72,44 @@ jobs:
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Decode signing keystore
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
# Reconstructs the release keystore from a base64-encoded
|
||||
# CI secret so every tagged build is signed with the same
|
||||
# key. Without this, each CI runner would generate its own
|
||||
# debug keystore and Android would refuse to upgrade an
|
||||
# existing install (signature mismatch).
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
run: |
|
||||
if [ -z "${ANDROID_KEYSTORE_B64}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_B64 secret is missing — release builds need a consistent signing key"
|
||||
exit 1
|
||||
fi
|
||||
KEYSTORE_PATH="${RUNNER_TEMP}/minstrel-release.keystore"
|
||||
echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "${KEYSTORE_PATH}"
|
||||
echo "ANDROID_KEYSTORE_PATH=${KEYSTORE_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Build release APK
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: flutter build apk --release
|
||||
# Inject the tag (sans leading 'v') as the build's --build-name
|
||||
# so PackageInfo.version matches what /api/client/version
|
||||
# reports — otherwise the in-app update banner would always
|
||||
# think the user is behind because pubspec.yaml's static
|
||||
# version drifts from the actual release tag.
|
||||
#
|
||||
# ANDROID_KEYSTORE_PATH is set by the previous step;
|
||||
# build.gradle.kts picks it up and signs the release APK with
|
||||
# the operator's persistent keystore.
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
flutter build apk --release --build-name="${TAG}"
|
||||
|
||||
- name: Upload debug APK artifact
|
||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')
|
||||
@@ -87,14 +122,15 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
# Look up the release ID for this tag (release must exist already;
|
||||
# release.yml creates it as part of the server-side release flow).
|
||||
# Look up the release ID for this tag. The release object must
|
||||
# exist already — operator creates it (or release.yml will, in
|
||||
# a future enhancement) before pushing the tag.
|
||||
RELEASE_ID=$(curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -oP '"id":\s*\K[0-9]+' | head -1)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
@@ -102,6 +138,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
curl -fsSL \
|
||||
-H "Authorization: token ${RELEASE_TOKEN}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk"
|
||||
|
||||
@@ -50,9 +50,11 @@ jobs:
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
else
|
||||
echo "args=-t ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main"
|
||||
fi
|
||||
|
||||
@@ -60,7 +62,7 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" \
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
# In-app update flow (#397): on tag pushes only, fetch the APK
|
||||
@@ -72,14 +74,14 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
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 ${RELEASE_TOKEN}" \
|
||||
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
|
||||
@@ -93,4 +95,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: docker buildx build --push ${{ steps.tags.outputs.args }} .
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
+7
-1
@@ -15,7 +15,13 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
RUN go build -trimpath -ldflags="-s -w" -o /out/minstrel ./cmd/minstrel
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
|
||||
+34
-2
@@ -14,6 +14,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"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/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -146,9 +148,38 @@ func run() error {
|
||||
// import requests and reconciles them against the library. Short-circuits
|
||||
// to no-op when lidarr_config.enabled = false.
|
||||
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)
|
||||
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, logger.With("component", "lidarr"), bus)
|
||||
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.
|
||||
// Fatal on failure: a non-writable data_dir silently breaks every
|
||||
// downstream cache (playlist covers, artist art, album-cover fallback)
|
||||
@@ -173,7 +204,8 @@ func run() error {
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, 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{
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
|
||||
@@ -30,11 +30,38 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// Real release signing config — populated only when CI exports
|
||||
// ANDROID_KEYSTORE_PATH (decoded from a base64 secret) plus the
|
||||
// matching password/alias env vars. Without these (local
|
||||
// `flutter build apk --release` runs), the release build falls
|
||||
// through to the debug keystore so local builds still work.
|
||||
//
|
||||
// Why this matters: Flutter's default `flutter run --release` and
|
||||
// CI's earlier setup both signed with the per-machine debug
|
||||
// keystore (~/.android/debug.keystore). Every CI runner generated
|
||||
// its own debug key, so consecutive release APKs had different
|
||||
// signatures and Android refused to upgrade an existing install.
|
||||
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystorePath)
|
||||
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePath != null && keystorePath.isNotEmpty()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Local-only fallback so `flutter run --release` works
|
||||
// without the keystore. CI must export ANDROID_KEYSTORE_PATH.
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -12,6 +13,15 @@ class AdminQuarantineScreen extends ConsumerWidget {
|
||||
@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,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -13,6 +14,15 @@ class AdminRequestsScreen extends ConsumerWidget {
|
||||
@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
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/home_data.dart';
|
||||
import '../../models/home_index.dart';
|
||||
import '../../models/track.dart';
|
||||
|
||||
/// LibraryApi wraps the server's native /api/* library surface.
|
||||
@@ -27,6 +28,24 @@ class LibraryApi {
|
||||
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
|
||||
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
||||
/// it cares about, so passing the whole body is correct.
|
||||
|
||||
@@ -12,9 +12,16 @@ class LibraryListsApi {
|
||||
final Dio _dio;
|
||||
|
||||
Future<Paged<ArtistRef>> listArtists({int limit = 50, int offset = 0}) async {
|
||||
// Server mounts the artists list at /api/artists (handleListArtists),
|
||||
// not /api/library/artists. Albums use /api/library/albums for
|
||||
// historical reasons; the paths aren't symmetric.
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'/api/library/artists',
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
'/api/artists',
|
||||
queryParameters: {
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'sort': 'alpha',
|
||||
},
|
||||
);
|
||||
return Paged.fromJson(r.data ?? const {}, ArtistRef.fromJson);
|
||||
}
|
||||
|
||||
@@ -38,4 +38,12 @@ class MeApi {
|
||||
);
|
||||
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,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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'cache/metadata_prefetcher.dart';
|
||||
import 'cache/prefetcher.dart';
|
||||
import 'cache/sync_controller.dart';
|
||||
import 'shared/live_events_dispatcher.dart';
|
||||
import 'shared/routing.dart';
|
||||
import 'theme/theme_data.dart';
|
||||
import 'theme/theme_mode_provider.dart';
|
||||
@@ -27,6 +29,16 @@ class _MinstrelAppState extends ConsumerState<MinstrelApp> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||
|
||||
const _kServerUrl = 'server_url';
|
||||
const _kSessionToken = 'session_token';
|
||||
const _kCurrentUser = 'current_user';
|
||||
const _kServerUrl = 'server_url';
|
||||
const _kSessionToken = 'session_token';
|
||||
const _kCurrentUser = 'current_user';
|
||||
const _kTzLastSentAt = 'tz_last_sent_at';
|
||||
const _weeklyMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
||||
(ref) => const FlutterSecureStorage(),
|
||||
@@ -29,7 +34,12 @@ class AuthController extends AsyncNotifier<User?> {
|
||||
_storage = ref.watch(secureStorageProvider);
|
||||
final raw = await _storage.read(key: _kCurrentUser);
|
||||
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 {
|
||||
@@ -42,6 +52,8 @@ class AuthController extends AsyncNotifier<User?> {
|
||||
await _storage.write(key: _kCurrentUser, value: userJson);
|
||||
ref.invalidate(sessionTokenProvider);
|
||||
state = AsyncData(User.fromJson(jsonDecode(userJson) as Map<String, dynamic>));
|
||||
// ignore: unawaited_futures
|
||||
_sendTimezoneIfStale();
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
@@ -50,6 +62,27 @@ class AuthController extends AsyncNotifier<User?> {
|
||||
ref.invalidate(sessionTokenProvider);
|
||||
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 =
|
||||
|
||||
+13
-1
@@ -34,12 +34,18 @@ extension ArtistRefDriftWrite on ArtistRef {
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +88,12 @@ extension TrackRefDriftWrite on TrackRef {
|
||||
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,
|
||||
@@ -90,7 +102,7 @@ extension CachedPlaylistAdapter on CachedPlaylist {
|
||||
isPublic: isPublic,
|
||||
systemVariant: systemVariant,
|
||||
trackCount: trackCount,
|
||||
coverUrl: '', // server-derived; cache doesn't persist
|
||||
coverUrl: '/api/playlists/$id/cover',
|
||||
ownerUsername: ownerUsername,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
|
||||
+39
-6
@@ -83,6 +83,27 @@ class AudioCacheManager {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Registers a file already on disk in the cache index. Intended for
|
||||
/// the streaming path (LockCachingAudioSource) which writes the file
|
||||
/// itself; we need an index row so eviction can find and delete it
|
||||
/// when usage exceeds the cap. `source` defaults to incidental so
|
||||
/// stream-cached tracks are first to be evicted.
|
||||
Future<void> registerStreamCache(
|
||||
String trackId,
|
||||
String path,
|
||||
int sizeBytes, {
|
||||
CacheSource source = CacheSource.incidental,
|
||||
}) async {
|
||||
await _db.into(_db.audioCacheIndex).insertOnConflictUpdate(
|
||||
AudioCacheIndexCompanion.insert(
|
||||
trackId: trackId,
|
||||
path: path,
|
||||
sizeBytes: sizeBytes,
|
||||
source: source,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a track from the index AND deletes the file.
|
||||
Future<void> unpin(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
@@ -96,13 +117,25 @@ class AudioCacheManager {
|
||||
.go();
|
||||
}
|
||||
|
||||
/// Total bytes used by the cache.
|
||||
/// Total bytes used by the cache. Walks the cache directory directly
|
||||
/// instead of summing the index, because the streaming-as-you-play
|
||||
/// path (audio_handler's LockCachingAudioSource) writes files
|
||||
/// without registering an index row. The index sum would always
|
||||
/// understate (often to zero) for users who only stream.
|
||||
Future<int> usageBytes() async {
|
||||
final result = await _db.customSelect(
|
||||
'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index',
|
||||
readsFrom: {_db.audioCacheIndex},
|
||||
).getSingle();
|
||||
return result.read<int>('total');
|
||||
final dir = Directory(await _tracksDir());
|
||||
if (!await dir.exists()) return 0;
|
||||
var total = 0;
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
total += await entity.length();
|
||||
} catch (_) {
|
||||
// Race against concurrent writes / deletes — just skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Evicts files until usage ≤ targetBytes. Eviction order:
|
||||
|
||||
+40
-1
@@ -5,12 +5,24 @@
|
||||
// - 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>`)
|
||||
@@ -28,10 +40,26 @@ Stream<T> cacheFirst<D, T>({
|
||||
required Future<void> Function() fetchAndPopulate,
|
||||
required T Function(List<D>) toResult,
|
||||
required Future<bool> Function() isOnline,
|
||||
bool alwaysRefresh = false,
|
||||
String? tag,
|
||||
}) async* {
|
||||
// Tracks whether we've already kicked off a stale-while-revalidate
|
||||
// refresh for this stream subscription, so we don't fire one on every
|
||||
// drift re-emission (otherwise the populate cycles forever).
|
||||
var revalidated = false;
|
||||
|
||||
await for (final rows in driftStream) {
|
||||
if (rows.isNotEmpty) {
|
||||
yield toResult(rows);
|
||||
// Stale-while-revalidate: yield cache immediately, then kick off
|
||||
// a REST refresh in the background. Drift watch() picks up the
|
||||
// resulting writes and re-emits via this same stream loop.
|
||||
// Useful for aggregate lists (e.g. playlists) where the server
|
||||
// may have rows the local sync didn't pick up.
|
||||
if (alwaysRefresh && !revalidated && await isOnline()) {
|
||||
revalidated = true;
|
||||
unawaited(_safeFetch(fetchAndPopulate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (await isOnline()) {
|
||||
@@ -39,7 +67,10 @@ Stream<T> cacheFirst<D, T>({
|
||||
await fetchAndPopulate();
|
||||
// The drift watch() stream re-emits with the populated rows on
|
||||
// the next loop iteration. Don't yield here.
|
||||
} catch (_) {
|
||||
} catch (e, st) {
|
||||
if (tag != null) {
|
||||
debugPrint('cacheFirst[$tag]: fetchAndPopulate failed: $e\n$st');
|
||||
}
|
||||
yield toResult(rows); // empty result; caller surfaces error
|
||||
}
|
||||
} else {
|
||||
@@ -47,3 +78,11 @@ Stream<T> cacheFirst<D, T>({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _safeFetch(Future<void> Function() fn) async {
|
||||
try {
|
||||
await fn();
|
||||
} catch (_) {
|
||||
// Background revalidate — swallow; UI already showed cached state.
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -5,9 +5,31 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
/// 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".
|
||||
final connectivityProvider = StreamProvider<bool>((ref) {
|
||||
///
|
||||
/// 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();
|
||||
return c.onConnectivityChanged.map(
|
||||
(results) => results.any((r) => r != ConnectivityResult.none),
|
||||
);
|
||||
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
+101
-1
@@ -115,6 +115,77 @@ class SyncMetadata extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Single-row cache of the /api/home response (#357 follow-up). The
|
||||
/// home screen is the first thing the user sees on app open; storing
|
||||
/// the last successful HomeData as JSON lets us yield it immediately
|
||||
/// before the REST round-trip resolves, eliminating the cold-start
|
||||
/// blank on slow / remote connections. Schema 3+.
|
||||
class CachedHomeSnapshot 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/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};
|
||||
}
|
||||
|
||||
/// 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};
|
||||
}
|
||||
|
||||
/// 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};
|
||||
}
|
||||
|
||||
enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
@@ -126,12 +197,16 @@ enum CacheSource { manual, autoLiked, autoPlaylist, autoPrefetch, incidental }
|
||||
CachedPlaylistTracks,
|
||||
AudioCacheIndex,
|
||||
SyncMetadata,
|
||||
CachedHomeSnapshot,
|
||||
CachedHistorySnapshot,
|
||||
CachedQuarantineMine,
|
||||
CachedHomeIndex,
|
||||
])
|
||||
class AppDb extends _$AppDb {
|
||||
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 6;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -147,6 +222,31 @@ class AppDb extends _$AppDb {
|
||||
// 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 for #357 drift-first
|
||||
// home page. No existing rows to migrate — table starts
|
||||
// empty; the first /api/home fetch populates it.
|
||||
await m.createTable(cachedHomeSnapshot);
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+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,74 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart';
|
||||
import '../models/home_data.dart';
|
||||
|
||||
/// Pre-warms the drift cache for likely-tap targets. Conservative on
|
||||
/// purpose: only warms artistProvider rows (single row, single round
|
||||
/// trip per id) and only ever fires once per id per session. Album
|
||||
/// detail is NOT prewarmed — the albumProvider auto-fetches its track
|
||||
/// list when missing, and pre-warming N albums fans out N parallel
|
||||
/// "fetch tracks" round trips that compete with the user's actual
|
||||
/// playback request for bandwidth.
|
||||
class MetadataPrefetcher {
|
||||
MetadataPrefetcher(this._ref) {
|
||||
_ref.listen<AsyncValue<HomeData>>(homeProvider, (_, next) {
|
||||
next.whenData(_warmHome);
|
||||
});
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
/// Per-session dedupe so re-rendering a screen (every UI rebuild
|
||||
/// fires the data: callback) doesn't trigger N more fetches for
|
||||
/// ids we've already warmed.
|
||||
final Set<String> _warmedArtists = {};
|
||||
|
||||
/// Cap per section. Covers what fits on screen without scrolling.
|
||||
static const _topN = 8;
|
||||
|
||||
/// Pre-warm artists. Albums are intentionally not pre-warmed —
|
||||
/// see class comment.
|
||||
void warmArtists(Iterable<String> ids) {
|
||||
var n = 0;
|
||||
for (final id in ids) {
|
||||
if (id.isEmpty) continue;
|
||||
if (!_warmedArtists.add(id)) continue; // already warmed
|
||||
if (n++ >= _topN) break;
|
||||
_swallow(_ref.read(artistProvider(id).future));
|
||||
}
|
||||
}
|
||||
|
||||
void _warmHome(HomeData h) {
|
||||
final artistIds = <String>{};
|
||||
for (final a in h.recentlyAddedAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final a in h.rediscoverAlbums.take(_topN)) {
|
||||
if (a.artistId.isNotEmpty) artistIds.add(a.artistId);
|
||||
}
|
||||
for (final ar in h.rediscoverArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final ar in h.lastPlayedArtists.take(_topN)) {
|
||||
if (ar.id.isNotEmpty) artistIds.add(ar.id);
|
||||
}
|
||||
for (final t in h.mostPlayedTracks.take(_topN)) {
|
||||
if (t.artistId.isNotEmpty) artistIds.add(t.artistId);
|
||||
}
|
||||
warmArtists(artistIds);
|
||||
}
|
||||
|
||||
/// Discards the return value and any error from a fire-and-forget
|
||||
/// provider read. We don't care about the value here — we only want
|
||||
/// the side effect of writing drift.
|
||||
void _swallow(Future<Object?> f) {
|
||||
f.then<void>((_) {}).onError((_, __) {});
|
||||
}
|
||||
}
|
||||
|
||||
/// Read once at app start to activate the prefetcher (e.g. wire it
|
||||
/// from a top-level Consumer or main.dart container override).
|
||||
final metadataPrefetcherProvider = Provider<MetadataPrefetcher>((ref) {
|
||||
return MetadataPrefetcher(ref);
|
||||
});
|
||||
+72
-2
@@ -1,6 +1,10 @@
|
||||
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';
|
||||
@@ -75,6 +79,14 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
|
||||
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 ----
|
||||
@@ -85,9 +97,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
upsertCount++;
|
||||
}
|
||||
for (final a in (upserts['album'] as List? ?? const [])) {
|
||||
final m = a as Map<String, dynamic>;
|
||||
await db.into(db.cachedAlbums).insertOnConflictUpdate(
|
||||
_albumFromJson(a as Map<String, dynamic>),
|
||||
_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 [])) {
|
||||
@@ -130,9 +145,12 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
upsertCount++;
|
||||
}
|
||||
for (final p in (upserts['playlist'] as List? ?? const [])) {
|
||||
final m = p as Map<String, dynamic>;
|
||||
await db.into(db.cachedPlaylists).insertOnConflictUpdate(
|
||||
_playlistFromJson(p as Map<String, dynamic>),
|
||||
_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 [])) {
|
||||
@@ -224,6 +242,15 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
);
|
||||
});
|
||||
|
||||
// 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,
|
||||
@@ -237,6 +264,49 @@ class SyncController extends AsyncNotifier<SyncResult?> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// 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' 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.
|
||||
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));
|
||||
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;
|
||||
}
|
||||
yield rows.first.toRef();
|
||||
}
|
||||
});
|
||||
|
||||
/// 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 ?? '',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -204,9 +205,14 @@ class _ResultTile extends StatelessWidget {
|
||||
color: fs.slate,
|
||||
child: row.imageUrl.isEmpty
|
||||
? Icon(Icons.album, color: fs.ash)
|
||||
: Image.network(row.imageUrl, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.album, color: fs.ash)),
|
||||
: CachedNetworkImage(
|
||||
imageUrl: row.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Icon(Icons.album, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -2,32 +2,105 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../models/album.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/track_row.dart';
|
||||
|
||||
class AlbumDetailScreen extends ConsumerWidget {
|
||||
const AlbumDetailScreen({required this.id, super.key});
|
||||
const AlbumDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional album reference passed by the caller (typically the
|
||||
/// tile they tapped) so the header can render immediately while
|
||||
/// the full provider loads tracks. Hydration only — provider data
|
||||
/// still wins once it arrives.
|
||||
final AlbumRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final live = ref.watch(albumProvider(id));
|
||||
|
||||
// Pick the best header info available: live data > seed > nothing.
|
||||
final headerTitle = live.value?.album.title.isNotEmpty == true
|
||||
? live.value!.album.title
|
||||
: seed?.title ?? '';
|
||||
final headerArtist = live.value?.album.artistName.isNotEmpty == true
|
||||
? live.value!.album.artistName
|
||||
: seed?.artistName ?? '';
|
||||
|
||||
Widget header() => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headerTitle,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: fs.display.fontFamily,
|
||||
fontSize: 22,
|
||||
),
|
||||
),
|
||||
if (headerArtist.isNotEmpty)
|
||||
Text(headerArtist, style: TextStyle(color: fs.ash)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: ref.watch(albumProvider(id)).when(
|
||||
body: live.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => seed != null
|
||||
? ListView(children: [
|
||||
header(),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
])
|
||||
: const Center(child: CircularProgressIndicator()),
|
||||
data: (r) => ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(width: 96, height: 96, color: fs.slate),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: ServerImage(
|
||||
url: '/api/albums/$id/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -5,34 +5,75 @@ import 'package:go_router/go_router.dart';
|
||||
import '../api/endpoints/likes.dart';
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
|
||||
class ArtistDetailScreen extends ConsumerWidget {
|
||||
const ArtistDetailScreen({required this.id, super.key});
|
||||
const ArtistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional artist reference from the caller. Lets the screen render
|
||||
/// the name + avatar immediately while albums load.
|
||||
final ArtistRef? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artist = ref.watch(artistProvider(id));
|
||||
final albums = ref.watch(artistAlbumsProvider(id));
|
||||
|
||||
// Resolve which artist info to render in the header. Live wins
|
||||
// when present and populated; seed fills the gap during the
|
||||
// first frame after navigation.
|
||||
final liveArtist = artist.value;
|
||||
final hasLiveName = liveArtist != null && liveArtist.name.isNotEmpty;
|
||||
final effective = hasLiveName ? liveArtist : (seed ?? liveArtist);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
backgroundColor: fs.obsidian,
|
||||
body: artist.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
data: (a) => ListView(children: [
|
||||
// While loading: render header from seed if available so the
|
||||
// page isn't blank.
|
||||
loading: () => seed == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _artistBody(context, ref, seed!, albums, fs),
|
||||
data: (_) => _artistBody(context, ref, effective ?? seed!, albums, fs),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _artistBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ArtistRef a,
|
||||
AsyncValue<List<AlbumRef>> albums,
|
||||
FabledSwordTheme fs,
|
||||
) =>
|
||||
ListView(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 96, height: 96,
|
||||
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
// Server derives artist cover from a representative
|
||||
// album. Drift cache doesn't persist that pointer, so
|
||||
// mirror the trick client-side: reuse the first album
|
||||
// returned by artistAlbumsProvider. Falls back to
|
||||
// slate while albums are loading or empty.
|
||||
child: _ArtistAvatar(
|
||||
serverCoverUrl: a.coverUrl,
|
||||
albums: albums,
|
||||
fs: fs,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
@@ -51,10 +92,31 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () async {
|
||||
final tracks = await ref.read(artistTracksProvider(id).future);
|
||||
if (tracks.isEmpty) return;
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
ref.read(playerActionsProvider).playTracks(shuffled);
|
||||
try {
|
||||
final tracks =
|
||||
await ref.read(artistTracksProvider(id).future);
|
||||
if (tracks.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No tracks found for this artist yet.')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final shuffled = [...tracks]..shuffle();
|
||||
await ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(shuffled);
|
||||
} catch (e) {
|
||||
debugPrint('artist_detail: play failed: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Couldn't start playback: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -68,23 +130,79 @@ class ArtistDetailScreen extends ConsumerWidget {
|
||||
albums.when(
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
|
||||
data: (list) => GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(album: album, onTap: () => context.push('/albums/${album.id}'));
|
||||
},
|
||||
),
|
||||
data: (list) {
|
||||
return LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW =
|
||||
(constraints.maxWidth - sidePad * 2 - gap * (cols - 1)) /
|
||||
cols;
|
||||
// Card content: cover (cellW - 16) + 8 + title (≤2 lines
|
||||
// ≈ 36) + slack. Artist line is suppressed in this
|
||||
// grid (showArtist: false) since the page header already
|
||||
// names the artist. Slack is generous on purpose — line-
|
||||
// height variations would otherwise overflow by 1px.
|
||||
final cellH = (cellW - 16) + 8 + 36 + 8;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(sidePad, 0, sidePad, 16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (_, i) {
|
||||
final AlbumRef album = list[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
showArtist: false,
|
||||
onTap: () =>
|
||||
context.push('/albums/${album.id}', extra: album),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Renders the artist's avatar. Server-emitted coverUrl wins when
|
||||
/// non-empty; otherwise we mirror the server's "use the first album's
|
||||
/// cover" rule client-side via the loaded album list.
|
||||
class _ArtistAvatar extends StatelessWidget {
|
||||
const _ArtistAvatar({
|
||||
required this.serverCoverUrl,
|
||||
required this.albums,
|
||||
required this.fs,
|
||||
});
|
||||
final String serverCoverUrl;
|
||||
final AsyncValue<List<AlbumRef>> albums;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (serverCoverUrl.isNotEmpty) {
|
||||
return ServerImage(
|
||||
url: serverCoverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
final firstId = albums.value?.isNotEmpty == true ? albums.value!.first.id : null;
|
||||
if (firstId == null) {
|
||||
return Container(color: fs.slate);
|
||||
}
|
||||
return ServerImage(
|
||||
url: '/api/albums/$firstId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,16 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/errors.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../cache/tile_providers.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/system_playlists_status.dart';
|
||||
import '../models/track.dart';
|
||||
import '../playlists/playlists_provider.dart';
|
||||
import '../playlists/widgets/playlist_card.dart';
|
||||
import '../playlists/widgets/playlist_placeholder_card.dart';
|
||||
import '../shared/delayed_loading.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 'library_providers.dart';
|
||||
import 'widgets/album_card.dart';
|
||||
@@ -22,13 +21,18 @@ import 'widgets/artist_card.dart';
|
||||
import 'widgets/compact_track_card.dart';
|
||||
import 'widgets/horizontal_scroll_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 {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final home = ref.watch(homeProvider);
|
||||
final index = ref.watch(homeIndexProvider);
|
||||
final allPlaylists = ref.watch(playlistsListProvider('all'));
|
||||
final status = ref.watch(systemPlaylistsStatusProvider);
|
||||
return Scaffold(
|
||||
@@ -40,38 +44,36 @@ class HomeScreen extends ConsumerWidget {
|
||||
actions: const [MainAppBarActions(currentRoute: '/home')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: home.when(
|
||||
child: index.when(
|
||||
error: (e, _) {
|
||||
final code = e is DioException ? ApiError.fromDio(e).code : 'unknown';
|
||||
if (code == 'connection_refused') {
|
||||
return ConnectionErrorBanner(
|
||||
onRetry: () => ref.refresh(homeProvider),
|
||||
onRetry: () => ref.refresh(homeIndexProvider),
|
||||
);
|
||||
}
|
||||
return Center(child: Text('$e', style: TextStyle(color: fs.error)));
|
||||
},
|
||||
loading: () => const DelayedLoading(
|
||||
isLoading: true,
|
||||
whenReady: SizedBox.shrink(),
|
||||
whileDelayed:
|
||||
Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
loading: () => _HomeSkeleton(fs: fs),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albums: h.rediscoverAlbums,
|
||||
artists: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
onRefresh: () async => ref.refresh(homeIndexProvider.future),
|
||||
child: ListView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(ids: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albumIds: h.rediscoverAlbums,
|
||||
artistIds: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(ids: h.mostPlayedTracks),
|
||||
_LastPlayedSection(ids: h.lastPlayedArtists),
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -79,6 +81,146 @@ class HomeScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-tile widgets ────────────────────────────────────────────────
|
||||
|
||||
/// Duration of the skeleton→content cross-fade. 220ms reads as "tile
|
||||
/// settled into place" — longer drags, shorter feels like a hard cut.
|
||||
/// 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);
|
||||
|
||||
/// Album tile: skeleton until albumTileProvider yields a populated row.
|
||||
class _AlbumTile extends ConsumerWidget {
|
||||
const _AlbumTile({required this.id});
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncAlbum = ref.watch(albumTileProvider(id));
|
||||
final album = asyncAlbum.asData?.value;
|
||||
return AnimatedSwitcher(
|
||||
duration: _tileRevealDuration,
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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});
|
||||
final List<Playlist> playlists;
|
||||
@@ -125,13 +267,16 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Slot 1: For-You.
|
||||
final forYou = findFirst((p) => p.systemVariant == 'for_you');
|
||||
out.add(forYou != null
|
||||
? _RealPlaylist(forYou)
|
||||
: _PlaceholderPlaylist('For You', _variantFor('for-you', status)));
|
||||
|
||||
// Slots 2-4: Songs-like (real first, padded to 3).
|
||||
final 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)
|
||||
@@ -142,7 +287,6 @@ List<_PlaylistRowItem> _buildPlaylistsRow(
|
||||
: _PlaceholderPlaylist('Songs like…', _variantFor('songs-like', status)));
|
||||
}
|
||||
|
||||
// User-created trail (server returns most-recently-updated first).
|
||||
for (final p in ownedAll.where((p) => p.systemVariant == null)) {
|
||||
out.add(_RealPlaylist(p));
|
||||
}
|
||||
@@ -158,44 +302,38 @@ String _variantFor(String slot, SystemPlaylistsStatus s) {
|
||||
}
|
||||
|
||||
class _RecentlyAddedSection extends StatelessWidget {
|
||||
const _RecentlyAddedSection({required this.albums});
|
||||
final List<AlbumRef> albums;
|
||||
const _RecentlyAddedSection({required this.ids});
|
||||
final List<String> ids;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (albums.isEmpty) {
|
||||
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(albums, 25);
|
||||
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((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
_push(context, '/albums/${a.id}'),
|
||||
))
|
||||
.toList(),
|
||||
children: rows[i].map((id) => _AlbumTile(id: id)).toList(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _RediscoverSection extends StatelessWidget {
|
||||
const _RediscoverSection({required this.albums, required this.artists});
|
||||
final List<AlbumRef> albums;
|
||||
final List<ArtistRef> artists;
|
||||
const _RediscoverSection({required this.albumIds, required this.artistIds});
|
||||
final List<String> albumIds;
|
||||
final List<String> artistIds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (albums.isEmpty && artists.isEmpty) {
|
||||
if (albumIds.isEmpty && artistIds.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Rediscover',
|
||||
message:
|
||||
@@ -203,47 +341,35 @@ class _RediscoverSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (albums.isNotEmpty)
|
||||
if (albumIds.isNotEmpty)
|
||||
HorizontalScrollRow(
|
||||
title: 'Rediscover',
|
||||
children: albums
|
||||
.map((a) => AlbumCard(
|
||||
album: a,
|
||||
onTap: () =>
|
||||
_push(context, '/albums/${a.id}'),
|
||||
))
|
||||
.toList(),
|
||||
children: albumIds.map((id) => _AlbumTile(id: id)).toList(),
|
||||
),
|
||||
if (artists.isNotEmpty)
|
||||
if (artistIds.isNotEmpty)
|
||||
HorizontalScrollRow(
|
||||
title: albums.isEmpty ? 'Rediscover' : '',
|
||||
title: albumIds.isEmpty ? 'Rediscover' : '',
|
||||
height: 168,
|
||||
children: artists
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () =>
|
||||
_push(context, '/artists/${ar.id}'),
|
||||
))
|
||||
.toList(),
|
||||
children: artistIds.map((id) => _ArtistTile(id: id)).toList(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _MostPlayedSection extends StatelessWidget {
|
||||
const _MostPlayedSection({required this.tracks});
|
||||
final List<TrackRef> tracks;
|
||||
const _MostPlayedSection({required this.ids});
|
||||
final List<String> ids;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (tracks.isEmpty) {
|
||||
if (ids.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Most played',
|
||||
message: 'No plays to draw from. Listen to something.',
|
||||
);
|
||||
}
|
||||
final controller = ScrollController();
|
||||
final rows = _chunk(tracks, 25);
|
||||
final rows = _chunk(ids, 25);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
HorizontalScrollRow(
|
||||
@@ -251,12 +377,7 @@ class _MostPlayedSection extends StatelessWidget {
|
||||
height: 64,
|
||||
controller: controller,
|
||||
children: [
|
||||
for (var j = 0; j < rows[i].length; j++)
|
||||
CompactTrackCard(
|
||||
track: rows[i][j],
|
||||
sectionTracks: tracks,
|
||||
index: i * 25 + j,
|
||||
),
|
||||
for (final id in rows[i]) _TrackTile(id: id, sectionIds: ids),
|
||||
],
|
||||
),
|
||||
]);
|
||||
@@ -264,12 +385,12 @@ class _MostPlayedSection extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _LastPlayedSection extends StatelessWidget {
|
||||
const _LastPlayedSection({required this.artists});
|
||||
final List<ArtistRef> artists;
|
||||
const _LastPlayedSection({required this.ids});
|
||||
final List<String> ids;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (artists.isEmpty) {
|
||||
if (ids.isEmpty) {
|
||||
return const _EmptySection(
|
||||
title: 'Last played',
|
||||
message: 'No recent plays.',
|
||||
@@ -278,12 +399,7 @@ class _LastPlayedSection extends StatelessWidget {
|
||||
return HorizontalScrollRow(
|
||||
title: 'Last played',
|
||||
height: 168,
|
||||
children: artists
|
||||
.map((ar) => ArtistCard(
|
||||
artist: ar,
|
||||
onTap: () => _push(context, '/artists/${ar.id}'),
|
||||
))
|
||||
.toList(),
|
||||
children: ids.map((id) => _ArtistTile(id: id)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -314,8 +430,68 @@ class _EmptySection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _push(BuildContext context, String path) {
|
||||
context.push(path);
|
||||
/// 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) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/client.dart';
|
||||
@@ -13,6 +17,7 @@ import '../cache/db.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/home_data.dart';
|
||||
import '../models/home_index.dart';
|
||||
import '../models/track.dart';
|
||||
|
||||
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
||||
@@ -39,10 +44,189 @@ final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
||||
return LibraryApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
||||
/// Drift-first home data (#357 follow-up). The home screen is the first
|
||||
/// view the user sees on app open; without a local cache the cold-start
|
||||
/// blocks on the /api/home round-trip, which is the dominant felt-
|
||||
/// latency on slow / remote connections.
|
||||
///
|
||||
/// Cache layout: a single row in `cached_home_snapshot` storing the
|
||||
/// last successful HomeData as JSON. On stream subscription:
|
||||
///
|
||||
/// - If a row exists, yield it immediately (parsed from JSON) and
|
||||
/// kick off a background revalidation against /api/home (SWR).
|
||||
/// The drift watch() re-emits when the new row is written.
|
||||
/// - If no row exists yet (cold cache), fetch /api/home synchronously,
|
||||
/// upsert into drift, and emit. Subsequent emissions come from the
|
||||
/// drift watch as background revalidations land.
|
||||
///
|
||||
/// The HomeData JSON encodes back to the same wire shape /api/home
|
||||
/// emits, so the existing HomeData.fromJson handles both sources.
|
||||
final homeProvider = StreamProvider<HomeData>((ref) {
|
||||
final db = ref.watch(appDbProvider);
|
||||
return cacheFirst<CachedHomeSnapshotData, HomeData>(
|
||||
driftStream: db.select(db.cachedHomeSnapshot).watch(),
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getHome();
|
||||
await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate(
|
||||
CachedHomeSnapshotCompanion.insert(
|
||||
json: _encodeHomeData(fresh),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
},
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const HomeData(
|
||||
recentlyAddedAlbums: [],
|
||||
rediscoverAlbums: [],
|
||||
rediscoverArtists: [],
|
||||
mostPlayedTracks: [],
|
||||
lastPlayedArtists: [],
|
||||
)
|
||||
: HomeData.fromJson(jsonDecode(rows.first.json) as Map<String, dynamic>),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// SWR: when cached snapshot exists, still hit /api/home in the
|
||||
// background so the user sees the freshest curation. The cache
|
||||
// mostly serves the first frame, not the canonical state.
|
||||
alwaysRefresh: true,
|
||||
tag: 'home',
|
||||
);
|
||||
});
|
||||
|
||||
/// Drift-first per-item home index. Reads from cached_home_index
|
||||
/// (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',
|
||||
);
|
||||
});
|
||||
|
||||
/// Encodes HomeData back to the wire-format JSON shape /api/home
|
||||
/// emits, so HomeData.fromJson can round-trip through the drift cache.
|
||||
String _encodeHomeData(HomeData h) => jsonEncode({
|
||||
'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(),
|
||||
'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(),
|
||||
'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(),
|
||||
'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(),
|
||||
'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(),
|
||||
});
|
||||
|
||||
Map<String, dynamic> _albumToJson(AlbumRef a) => {
|
||||
'id': a.id,
|
||||
'title': a.title,
|
||||
'sort_title': a.sortTitle,
|
||||
'artist_id': a.artistId,
|
||||
'artist_name': a.artistName,
|
||||
'year': a.year,
|
||||
'track_count': a.trackCount,
|
||||
'duration_sec': a.durationSec,
|
||||
'cover_url': a.coverUrl,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _artistToJson(ArtistRef a) => {
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'sort_name': a.sortName,
|
||||
'album_count': a.albumCount,
|
||||
'cover_url': a.coverUrl,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _trackToJson(TrackRef t) => {
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'album_id': t.albumId,
|
||||
'album_title': t.albumTitle,
|
||||
'artist_id': t.artistId,
|
||||
'artist_name': t.artistName,
|
||||
'track_number': t.trackNumber,
|
||||
'disc_number': t.discNumber,
|
||||
'duration_sec': t.durationSec,
|
||||
'stream_url': t.streamUrl,
|
||||
};
|
||||
|
||||
/// 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().
|
||||
@@ -60,8 +244,14 @@ final artistProvider =
|
||||
toResult: (rows) => rows.isEmpty
|
||||
? const ArtistRef(id: '', name: '')
|
||||
: rows.first.toRef(),
|
||||
isOnline: () async =>
|
||||
(await ref.read(connectivityProvider.future)),
|
||||
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)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,8 +279,18 @@ final artistAlbumsProvider =
|
||||
final artist = r.readTableOrNull(db.cachedArtists);
|
||||
return album.toRef(artistName: artist?.name ?? '');
|
||||
}).toList(),
|
||||
isOnline: () async =>
|
||||
(await ref.read(connectivityProvider.future)),
|
||||
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)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -123,8 +323,10 @@ final artistTracksProvider =
|
||||
albumTitle: album?.title ?? '',
|
||||
);
|
||||
}).toList(),
|
||||
isOnline: () async =>
|
||||
(await ref.read(connectivityProvider.future)),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
tag: 'artistTracks($artistId)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -152,31 +354,97 @@ final albumProvider = StreamProvider.family<
|
||||
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
||||
]);
|
||||
|
||||
await for (final albumRows in albumQuery.watch()) {
|
||||
if (albumRows.isEmpty) {
|
||||
// Cold cache fallback
|
||||
if (await ref.read(connectivityProvider.future)) {
|
||||
try {
|
||||
final api = await ref.read(libraryApiProvider.future);
|
||||
final fresh = await api.getAlbum(albumId);
|
||||
await db.batch((b) {
|
||||
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
|
||||
b.insertAllOnConflictUpdate(db.cachedTracks,
|
||||
fresh.tracks.map((t) => t.toDrift()).toList());
|
||||
});
|
||||
// watch() re-emits with the populated rows; loop continues.
|
||||
} catch (_) {
|
||||
yield (
|
||||
album: const AlbumRef(id: '', title: '', artistId: ''),
|
||||
tracks: const <TrackRef>[],
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -186,6 +454,22 @@ final albumProvider = StreamProvider.family<
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -195,6 +479,14 @@ final albumProvider = StreamProvider.family<
|
||||
);
|
||||
}).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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -5,6 +8,13 @@ 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/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/tile_providers.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
@@ -15,7 +25,11 @@ import '../models/page.dart' as wire;
|
||||
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';
|
||||
@@ -37,33 +51,271 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
||||
return MeApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final _libraryArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listArtists();
|
||||
/// Paginated artist list. AsyncNotifier so the screen can call
|
||||
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
||||
/// pages append to the existing items rather than replacing them.
|
||||
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<ArtistRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listArtists(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listArtists(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — the next near-bottom event will retry. The current
|
||||
// partial list stays visible.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryArtistsProvider = AsyncNotifierProvider<
|
||||
_LibraryArtistsNotifier,
|
||||
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
||||
|
||||
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
||||
static const _pageSize = 50;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
Future<wire.Paged<AlbumRef>> build() async {
|
||||
final api = await ref.watch(_libraryListsApiProvider.future);
|
||||
return api.listAlbums(limit: _pageSize, offset: 0);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (_loadingMore) return;
|
||||
final cur = state.value;
|
||||
if (cur == null) return;
|
||||
if (cur.items.length >= cur.total) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final api = await ref.read(_libraryListsApiProvider.future);
|
||||
final next = await api.listAlbums(
|
||||
limit: _pageSize,
|
||||
offset: cur.items.length,
|
||||
);
|
||||
state = AsyncData(wire.Paged(
|
||||
items: [...cur.items, ...next.items],
|
||||
total: next.total,
|
||||
limit: next.limit,
|
||||
offset: 0,
|
||||
));
|
||||
} catch (_) {
|
||||
// Swallow — next scroll event will retry.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
||||
_LibraryAlbumsNotifier,
|
||||
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
||||
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
|
||||
final _libraryAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_libraryListsApiProvider.future)).listAlbums();
|
||||
/// 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)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'likedTrackIds',
|
||||
);
|
||||
});
|
||||
|
||||
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).history();
|
||||
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)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'likedAlbumIds',
|
||||
);
|
||||
});
|
||||
|
||||
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
||||
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)),
|
||||
alwaysRefresh: true,
|
||||
tag: 'likedArtistIds',
|
||||
);
|
||||
});
|
||||
|
||||
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
||||
});
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
||||
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
||||
});
|
||||
|
||||
final _quarantineProvider = FutureProvider<List<QuarantineMineRow>>((ref) async {
|
||||
return (await ref.watch(_meApiProvider.future)).quarantineMine();
|
||||
});
|
||||
// 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});
|
||||
@@ -131,25 +383,53 @@ class _ArtistsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryArtistsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
data: (page) {
|
||||
// Warm details for the first screenful so taps are instant.
|
||||
ref
|
||||
.read(metadataPrefetcherProvider)
|
||||
.warmArtists(page.items.map((a) => a.id));
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryArtistsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: page.items[i],
|
||||
onTap: () => ctx.push('/artists/${page.items[i].id}'),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryArtistsProvider);
|
||||
await ref.read(_libraryArtistsProvider.future);
|
||||
},
|
||||
// Listen for scroll-near-bottom and ask the notifier
|
||||
// to fetch the next page. 800px lookahead so the next
|
||||
// page lands before the user reaches the visible end.
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryArtistsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final artist = page.items[i];
|
||||
return ArtistCard(
|
||||
artist: artist,
|
||||
onTap: () => ctx.push('/artists/${artist.id}',
|
||||
extra: artist),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,25 +443,66 @@ class _AlbumsTab extends ConsumerWidget {
|
||||
return ref.watch(_libraryAlbumsProvider).when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (page) => page.items.isEmpty
|
||||
data: (page) {
|
||||
return page.items.isEmpty
|
||||
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_libraryAlbumsProvider.future),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: page.items[i],
|
||||
onTap: () => ctx.push('/albums/${page.items[i].id}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
onRefresh: () async {
|
||||
ref.invalidate(_libraryAlbumsProvider);
|
||||
await ref.read(_libraryAlbumsProvider.future);
|
||||
},
|
||||
// Same responsive 3-up grid as the artist detail
|
||||
// album list — LayoutBuilder + AlbumCard sized to the
|
||||
// cell, mainAxisExtent matched to actual card height.
|
||||
child: LayoutBuilder(builder: (ctx, constraints) {
|
||||
const cols = 3;
|
||||
const sidePad = 8.0;
|
||||
const gap = 8.0;
|
||||
final cellW = (constraints.maxWidth -
|
||||
sidePad * 2 -
|
||||
gap * (cols - 1)) /
|
||||
cols;
|
||||
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
||||
// + artist line (~16) + slack. Slack is generous on
|
||||
// purpose — line-height + font scaling variations
|
||||
// would otherwise overflow the cell by a pixel
|
||||
// (logged as a noisy RenderFlex warning).
|
||||
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >=
|
||||
n.metrics.maxScrollExtent - 800) {
|
||||
ref
|
||||
.read(_libraryAlbumsProvider.notifier)
|
||||
.loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(sidePad),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
mainAxisExtent: cellH,
|
||||
mainAxisSpacing: gap,
|
||||
crossAxisSpacing: gap,
|
||||
),
|
||||
itemCount: page.items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final album = page.items[i];
|
||||
return AlbumCard(
|
||||
album: album,
|
||||
width: cellW,
|
||||
titleMaxLines: 2,
|
||||
onTap: () => ctx.push('/albums/${album.id}',
|
||||
extra: album),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -227,71 +548,82 @@ class _LikedTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final tracksA = ref.watch(_likedTracksProvider);
|
||||
final albumsA = ref.watch(_likedAlbumsProvider);
|
||||
final artistsA = ref.watch(_likedArtistsProvider);
|
||||
// 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 t = tracksA.value;
|
||||
final al = albumsA.value;
|
||||
final ar = artistsA.value;
|
||||
if (t == null || al == null || ar == null) {
|
||||
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 (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
||||
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(_likedTracksProvider.future),
|
||||
ref.refresh(_likedAlbumsProvider.future),
|
||||
ref.refresh(_likedArtistsProvider.future),
|
||||
ref.refresh(_likedTrackIdsProvider.future),
|
||||
ref.refresh(_likedAlbumIdsProvider.future),
|
||||
ref.refresh(_likedArtistIdsProvider.future),
|
||||
]);
|
||||
},
|
||||
child: ListView(children: [
|
||||
if (ar.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Artists', count: ar.total),
|
||||
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: ar.items.length,
|
||||
itemBuilder: (ctx, i) => ArtistCard(
|
||||
artist: ar.items[i],
|
||||
onTap: () => ctx.push('/artists/${ar.items[i].id}'),
|
||||
),
|
||||
itemCount: artistIds.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_LikedArtistTile(id: artistIds[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (al.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Albums', count: al.total),
|
||||
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: al.items.length,
|
||||
itemBuilder: (ctx, i) => AlbumCard(
|
||||
album: al.items[i],
|
||||
onTap: () => ctx.push('/albums/${al.items[i].id}'),
|
||||
),
|
||||
itemCount: albumIds.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_LikedAlbumTile(id: albumIds[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (t.items.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Tracks', count: t.total),
|
||||
...t.items.asMap().entries.map((e) {
|
||||
return TrackRow(
|
||||
track: e.value,
|
||||
onTap: () => ref
|
||||
.read(playerActionsProvider)
|
||||
.playTracks(t.items, initialIndex: e.key),
|
||||
);
|
||||
}),
|
||||
if (trackIds.isNotEmpty) ...[
|
||||
_SectionHeader(label: 'Tracks', count: trackIds.length),
|
||||
...trackIds.map(
|
||||
(id) => _LikedTrackRow(id: id, sectionIds: trackIds),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
@@ -299,13 +631,102 @@ class _LikedTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(_quarantineProvider).when(
|
||||
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
|
||||
@@ -317,7 +738,7 @@ class _HiddenTab extends ConsumerWidget {
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(_quarantineProvider.future),
|
||||
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: rows.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
||||
@@ -328,12 +749,12 @@ class _HiddenTab extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _QuarantineTile extends StatelessWidget {
|
||||
class _QuarantineTile extends ConsumerWidget {
|
||||
const _QuarantineTile({required this.row});
|
||||
final QuarantineMineRow row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final reasonLabel = switch (row.reason) {
|
||||
'bad_rip' => 'Bad rip',
|
||||
@@ -342,9 +763,25 @@ class _QuarantineTile extends StatelessWidget {
|
||||
'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(
|
||||
@@ -361,17 +798,41 @@ class _QuarantineTile extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: 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)),
|
||||
),
|
||||
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(Icons.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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../library_providers.dart';
|
||||
import 'play_circle_button.dart';
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
const AlbumCard({required this.album, required this.onTap, super.key});
|
||||
class AlbumCard extends ConsumerWidget {
|
||||
const AlbumCard({
|
||||
required this.album,
|
||||
required this.onTap,
|
||||
this.width = 140,
|
||||
this.titleMaxLines = 1,
|
||||
this.showArtist = true,
|
||||
super.key,
|
||||
});
|
||||
final AlbumRef album;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Outer width of the card. Cover is square at width - 16 (8dp
|
||||
/// horizontal padding either side). Default suits horizontal lists;
|
||||
/// grids should pass the cell width so the card scales down.
|
||||
final double width;
|
||||
|
||||
/// Lets callers (e.g. the artist detail grid) allow the title to
|
||||
/// wrap to a second line so it isn't truncated to a single
|
||||
/// ellipsized character at narrower widths.
|
||||
final int titleMaxLines;
|
||||
|
||||
/// Suppress the artist name. Useful on surfaces where the artist is
|
||||
/// already implied by the page header (e.g. artist detail).
|
||||
final bool showArtist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: album.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: ServerImage(url: album.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
final coverSize = width - 16;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Stack: cover image + overlaid play button at bottom-right.
|
||||
Stack(
|
||||
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,45 +1,84 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../models/artist.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../library_providers.dart';
|
||||
import 'play_circle_button.dart';
|
||||
|
||||
class ArtistCard extends StatelessWidget {
|
||||
class ArtistCard extends ConsumerWidget {
|
||||
const ArtistCard({required this.artist, required this.onTap, super.key});
|
||||
final ArtistRef artist;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
||||
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||
return SizedBox(
|
||||
width: 140,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Stack: circular avatar + overlaid play button at bottom-right.
|
||||
// The avatar is a circle (ClipOval) — bottom-right of its
|
||||
// bounding box still places the button inside the visible area
|
||||
// because the button is small relative to the radius.
|
||||
Stack(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
color: fs.slate,
|
||||
child: artist.coverUrl.isEmpty
|
||||
? SvgPicture.asset('assets/svg/album-fallback.svg',
|
||||
fit: BoxFit.cover)
|
||||
: ServerImage(url: artist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
child: PlayCircleButton(
|
||||
onPressed: () => _playArtistShuffle(ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,96 @@
|
||||
import 'package:flutter/material.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(
|
||||
Icons.play_arrow,
|
||||
color: fs.parchment,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 'cached_indicator.dart';
|
||||
|
||||
class TrackRow extends StatelessWidget {
|
||||
class TrackRow extends ConsumerWidget {
|
||||
const TrackRow({
|
||||
required this.track,
|
||||
required this.onTap,
|
||||
@@ -23,48 +25,75 @@ class TrackRow extends StatelessWidget {
|
||||
final bool actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mins = (track.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||
final secs = (track.durationSec % 60).toString().padLeft(2, '0');
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
if (track.trackNumber != null)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(
|
||||
track.trackNumber.toString(),
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
// Watch the currently-playing media item so the row's accent
|
||||
// highlight tracks playback state. Matches _QueueRow's visual
|
||||
// treatment in queue_screen.dart so the "you are here" cue is
|
||||
// consistent across album / playlist / queue surfaces.
|
||||
final currentId = ref.watch(mediaItemProvider).value?.id;
|
||||
final isCurrent = currentId != null && currentId == track.id;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? fs.iron : null,
|
||||
border: isCurrent
|
||||
? 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(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
CachedIndicator(trackId: track.id),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (trailing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: trailing!,
|
||||
),
|
||||
Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
]),
|
||||
),
|
||||
CachedIndicator(trackId: track.id),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (trailing != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: trailing!,
|
||||
),
|
||||
if (actions) TrackActionsButton(track: track),
|
||||
]),
|
||||
if (actions) TrackActionsButton(track: track),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,83 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -12,8 +12,27 @@ import 'album_cover_cache.dart';
|
||||
|
||||
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
MinstrelAudioHandler() {
|
||||
_player.playbackEventStream.listen(_broadcastState);
|
||||
_player.playbackEventStream.listen(
|
||||
_broadcastState,
|
||||
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
||||
// failure, network drop) here. Without an error sink, the
|
||||
// player just goes quiet — exactly the "starts then stops"
|
||||
// symptom we hit.
|
||||
onError: (Object e, StackTrace st) {
|
||||
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
||||
},
|
||||
);
|
||||
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
||||
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
||||
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||
_player.loopModeStream.listen((_) => _broadcastState(null));
|
||||
// Watch buffered-position so we can register stream-cached files
|
||||
// in the audio cache index once they're fully downloaded. Without
|
||||
// this, files written by LockCachingAudioSource never appear in
|
||||
// the index and the eviction loop can't reclaim them.
|
||||
_player.bufferedPositionStream
|
||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||
}
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
@@ -22,6 +41,46 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
AlbumCoverCache? _coverCache;
|
||||
AudioCacheManager? _audioCacheManager;
|
||||
|
||||
/// Trackers to dedupe registration — once we've inserted an index row
|
||||
/// for a trackId, don't repeat the work on every buffered-position
|
||||
/// emit. Cleared on dispose only; surviving across queue rebuilds is
|
||||
/// fine because the index is itself the source of truth.
|
||||
final Set<String> _streamCacheRegistered = {};
|
||||
|
||||
/// Cached on first use so we don't hit the platform channel every
|
||||
/// time the buffered-position stream emits (~200ms cadence).
|
||||
String? _cacheDirPath;
|
||||
|
||||
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||
/// currentIndex (it tracks the actively-playing source through
|
||||
/// list mutations), and the resulting _onCurrentIndexChanged
|
||||
/// callbacks would push the wrong MediaItem onto the stream
|
||||
/// (queue.value[shifted_idx] != actively-playing track). When
|
||||
/// the fill completes, currentIndex == initialIndex, mediaItem
|
||||
/// is already correct, and we re-enable normal listener behavior.
|
||||
bool _suppressIndexUpdates = false;
|
||||
|
||||
/// Increments on every setQueueFromTracks call. The background
|
||||
/// fill task captures the value at start and bails if the live
|
||||
/// counter has moved on — without this, a stale fill will keep
|
||||
/// addAudioSource'ing tracks from the previous playlist into the
|
||||
/// new queue, leaving the player "locked" to a corrupted state.
|
||||
int _queueGeneration = 0;
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> get volumeStream => _player.volumeStream;
|
||||
double get volume => _player.volume;
|
||||
|
||||
/// Position stream for UI subscribers. just_audio emits roughly every
|
||||
/// 200ms while playing, which is what makes the seek bar advance
|
||||
/// smoothly. PlaybackState.updatePosition only changes on event
|
||||
/// transitions (play/pause/buffer), so it's too coarse for live
|
||||
/// scrubbing UI.
|
||||
Stream<Duration> get positionStream => _player.positionStream;
|
||||
Duration get position => _player.position;
|
||||
|
||||
void configure({
|
||||
required String baseUrl,
|
||||
required String? token,
|
||||
@@ -32,32 +91,86 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
_token = token;
|
||||
if (coverCache != null) _coverCache = coverCache;
|
||||
if (audioCacheManager != null) _audioCacheManager = audioCacheManager;
|
||||
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
||||
'tokenPresent=${token != null && token.isNotEmpty} '
|
||||
'coverCachePresent=${_coverCache != null} '
|
||||
'audioCachePresent=${_audioCacheManager != null}');
|
||||
}
|
||||
|
||||
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
||||
|
||||
// Bump the generation FIRST. Any in-flight _fillRemainingSources
|
||||
// from a previous play will see the mismatch on its next gen
|
||||
// check and stop calling player mutations — important so a stale
|
||||
// fill doesn't append old-playlist tracks into the new queue.
|
||||
final myGen = ++_queueGeneration;
|
||||
// Reset suppress flag in case a prior backward-fill bailed on
|
||||
// gen check before reaching its `finally`.
|
||||
_suppressIndexUpdates = false;
|
||||
|
||||
// Populate the visible queue + current mediaItem immediately so
|
||||
// the player UI reflects the user's tap before any source has
|
||||
// been built. Source list at the just_audio layer fills in
|
||||
// asynchronously below.
|
||||
final items = tracks.map(_toMediaItem).toList();
|
||||
queue.add(items);
|
||||
if (items.isNotEmpty) {
|
||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||
mediaItem.add(items[clampedInitial]);
|
||||
|
||||
// Fast path: build only the initial source so the player can
|
||||
// start. Remaining sources stream in via _fillRemainingSources()
|
||||
// in the background — addAudioSource for next/auto-advance
|
||||
// tracks, insertAudioSource for skipPrev tracks.
|
||||
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
||||
if (myGen != _queueGeneration) {
|
||||
debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('audio_handler.setQueueFromTracks: '
|
||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
||||
await _player.setAudioSources([initial], initialIndex: 0);
|
||||
|
||||
await _player.setAudioSources(
|
||||
sources,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
|
||||
// Kick the cover fetch for the initial item — async, doesn't block
|
||||
// playback. Subsequent track changes are handled by the
|
||||
// currentIndexStream listener.
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
unawaited(_fillRemainingSources(tracks, clampedInitial, myGen));
|
||||
}
|
||||
|
||||
/// Background fill of the rest of the just_audio source list after
|
||||
/// the initial source is playing. Forward direction first (most
|
||||
/// common skipNext target). Backward inserts shift the player's
|
||||
/// currentIndex; we suppress _onCurrentIndexChanged side effects
|
||||
/// for those so the mediaItem stream doesn't bounce to the wrong
|
||||
/// queue entry.
|
||||
///
|
||||
/// `gen` is the queue generation captured when this fill started.
|
||||
/// Every loop iteration checks against _queueGeneration; if a
|
||||
/// newer setQueueFromTracks has run (user tapped play on something
|
||||
/// else), bail immediately so we don't pollute the new queue with
|
||||
/// addAudioSource calls from this stale fill.
|
||||
Future<void> _fillRemainingSources(
|
||||
List<TrackRef> tracks, int initialIndex, int gen) async {
|
||||
for (var i = initialIndex + 1; i < tracks.length; i++) {
|
||||
if (gen != _queueGeneration) return;
|
||||
try {
|
||||
final src = await _buildAudioSource(tracks[i]);
|
||||
if (gen != _queueGeneration) return;
|
||||
await _player.addAudioSource(src);
|
||||
} catch (e) {
|
||||
debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
|
||||
}
|
||||
}
|
||||
if (initialIndex > 0) {
|
||||
_suppressIndexUpdates = true;
|
||||
try {
|
||||
for (var i = 0; i < initialIndex; i++) {
|
||||
if (gen != _queueGeneration) return;
|
||||
final src = await _buildAudioSource(tracks[i]);
|
||||
if (gen != _queueGeneration) return;
|
||||
await _player.insertAudioSource(i, src);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('audio_handler: backward fill failed: $e');
|
||||
} finally {
|
||||
// Only release the flag if we're still the active gen — a
|
||||
// newer setQueueFromTracks already reset it for itself.
|
||||
if (gen == _queueGeneration) _suppressIndexUpdates = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _resolveStreamUrl(TrackRef t) {
|
||||
@@ -86,7 +199,6 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (mgr != null) {
|
||||
final path = await mgr.pathFor(t.id);
|
||||
if (path != null) {
|
||||
debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path');
|
||||
return AudioSource.uri(Uri.file(path));
|
||||
}
|
||||
}
|
||||
@@ -110,17 +222,14 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
// pin / Download buttons cover the index path; LockCaching handles
|
||||
// the network optimization.
|
||||
if (mgr != null) {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3');
|
||||
debugPrint('audio_handler: cache miss for track.id=${t.id}, '
|
||||
'using LockCachingAudioSource → ${cacheFile.path}');
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final cacheFile = File('${_cacheDirPath!}/audio_cache/${t.id}.mp3');
|
||||
// ignore: experimental_member_use
|
||||
return LockCachingAudioSource(parsed,
|
||||
headers: headers, cacheFile: cacheFile);
|
||||
}
|
||||
|
||||
// 3. No manager configured: plain network source (legacy path).
|
||||
debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"');
|
||||
return AudioSource.uri(parsed, headers: headers);
|
||||
}
|
||||
|
||||
@@ -148,19 +257,68 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
queue.add([...queue.value, item]);
|
||||
}
|
||||
|
||||
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
// Stash album_id in extras so _loadArtForCurrentItem can pull it
|
||||
// back without re-walking the track list.
|
||||
extras: t.albumId.isEmpty ? null : {'album_id': t.albumId},
|
||||
);
|
||||
MediaItem _toMediaItem(TrackRef t) {
|
||||
// Stash album_id + artist_id in extras so widgets reconstructing
|
||||
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
||||
// "Go to album") have the IDs they need to navigate. Earlier code
|
||||
// only carried album_id which left "Go to artist" pushing
|
||||
// /artists/ (empty id, route 404).
|
||||
final extras = <String, dynamic>{};
|
||||
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
||||
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
||||
return MediaItem(
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artistName,
|
||||
album: t.albumTitle,
|
||||
duration: Duration(seconds: t.durationSec),
|
||||
extras: extras.isEmpty ? null : extras,
|
||||
);
|
||||
}
|
||||
|
||||
/// Once a track is fully buffered (LockCaching has written the whole
|
||||
/// file to disk), insert an audio_cache_index row so the file shows
|
||||
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
|
||||
/// cache manager isn't configured, no current track, the file isn't
|
||||
/// fully buffered yet, the on-disk file is missing, or we already
|
||||
/// registered this trackId during this subscription.
|
||||
Future<void> _maybeRegisterStreamCache() async {
|
||||
final mgr = _audioCacheManager;
|
||||
if (mgr == null) return;
|
||||
final current = mediaItem.value;
|
||||
if (current == null) return;
|
||||
final trackId = current.id;
|
||||
if (_streamCacheRegistered.contains(trackId)) return;
|
||||
|
||||
final dur = _player.duration;
|
||||
if (dur == null) return;
|
||||
final buf = _player.bufferedPosition;
|
||||
// 200ms slack for header bytes / encoding rounding.
|
||||
if (buf < dur - const Duration(milliseconds: 200)) return;
|
||||
|
||||
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
||||
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return;
|
||||
final size = await file.length();
|
||||
if (size <= 0) return;
|
||||
|
||||
_streamCacheRegistered.add(trackId);
|
||||
await mgr.registerStreamCache(trackId, path, size);
|
||||
}
|
||||
|
||||
void _onCurrentIndexChanged(int? idx) {
|
||||
if (idx == null) return;
|
||||
if (_suppressIndexUpdates) return;
|
||||
// Push the new track's MediaItem onto the mediaItem stream so
|
||||
// the player UI rebuilds with the new title/artist/album/cover.
|
||||
// Without this, the bar and full player stayed pinned to whichever
|
||||
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
||||
// of skip/auto-advance.
|
||||
final items = queue.value;
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
mediaItem.add(items[idx]);
|
||||
}
|
||||
unawaited(_loadArtForCurrentItem());
|
||||
}
|
||||
|
||||
@@ -198,7 +356,34 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
@override
|
||||
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||
|
||||
void _broadcastState(PlaybackEvent event) {
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await _player
|
||||
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
||||
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
final loop = switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => LoopMode.off,
|
||||
AudioServiceRepeatMode.one => LoopMode.one,
|
||||
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
||||
};
|
||||
await _player.setLoopMode(loop);
|
||||
}
|
||||
|
||||
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
||||
/// page audio to system volume — this is the in-app slider for parity
|
||||
/// with desktop/web; on mobile the system volume is the real control.
|
||||
Future<void> setVolume(double v) async {
|
||||
await _player.setVolume(v.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
// _broadcastState accepts a nullable event because the shuffle/repeat
|
||||
// listeners don't have one — we just want to re-emit PlaybackState
|
||||
// with up-to-date shuffleMode/repeatMode fields.
|
||||
void _broadcastState(PlaybackEvent? event) {
|
||||
final playing = _player.playing;
|
||||
playbackState.add(PlaybackState(
|
||||
controls: [
|
||||
@@ -206,7 +391,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {MediaAction.seek},
|
||||
// androidCompactActionIndices tells the system which controls
|
||||
// appear in the collapsed/lock-screen view. Without this, some
|
||||
// Android versions render the player without working buttons.
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
// systemActions enumerates which actions the system can invoke
|
||||
// on us — without play/pause/skip in here, taps on lock-screen
|
||||
// controls don't route back to the handler on Android 13+.
|
||||
systemActions: const {
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.skipToNext,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.seek,
|
||||
},
|
||||
processingState: switch (_player.processingState) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
@@ -218,7 +416,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
queueIndex: event.currentIndex,
|
||||
queueIndex: event?.currentIndex ?? _player.currentIndex,
|
||||
shuffleMode: _player.shuffleModeEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: switch (_player.loopMode) {
|
||||
LoopMode.off => AudioServiceRepeatMode.none,
|
||||
LoopMode.one => AudioServiceRepeatMode.one,
|
||||
LoopMode.all => AudioServiceRepeatMode.all,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/server_image.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'album_color_extractor.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
class NowPlayingScreen extends ConsumerWidget {
|
||||
/// Hero tag shared between the mini player's cover and the full-screen
|
||||
/// _AlbumArt's cover so tapping the mini bar animates the artwork from
|
||||
/// the bar's footprint to the full-screen size. Stable per-route (not
|
||||
/// keyed by media.id) so the transition works regardless of what's
|
||||
/// playing.
|
||||
const String kPlayerCoverHeroTag = 'player-cover';
|
||||
|
||||
/// Duration for the AnimatedSwitcher / AnimatedContainer track-change
|
||||
/// crossfade. ~300ms reads as a smooth transition without dragging.
|
||||
const Duration _trackChangeDuration = Duration(milliseconds: 300);
|
||||
|
||||
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
||||
/// transition (see routing.dart). Dismisses three ways:
|
||||
///
|
||||
/// - System back / leading button → Navigator.pop
|
||||
/// - Vertical drag down past threshold → Navigator.pop (matches the
|
||||
/// slide-down animation users expect from a "pull down to close"
|
||||
/// sheet)
|
||||
/// - Tap of the mini player ascends here; reverse motion descends back.
|
||||
class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||
const NowPlayingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
||||
}
|
||||
|
||||
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
// Cumulative drag distance used to decide if a vertical drag should
|
||||
// dismiss. Reset on each drag start.
|
||||
double _dragOffset = 0;
|
||||
|
||||
void _onDragStart(DragStartDetails _) {
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
_dragOffset += d.delta.dy;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
// Pop when either the user has dragged > 80px down OR flicked
|
||||
// downward at speed (>500 px/s). Either should feel responsive
|
||||
// without dismissing on accidental drags.
|
||||
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
|
||||
if (_dragOffset > 80 || flicked) {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
_dragOffset = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
@@ -20,87 +74,408 @@ class NowPlayingScreen extends ConsumerWidget {
|
||||
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
||||
}
|
||||
|
||||
final pos = playback?.updatePosition ?? Duration.zero;
|
||||
// Use positionProvider (just_audio's positionStream, ~200ms) for
|
||||
// the seek bar so it scrubs live; PlaybackState.updatePosition
|
||||
// only fires on state transitions and would leave the bar frozen
|
||||
// between them.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
final albumId = (media.extras?['album_id'] as String?) ?? '';
|
||||
|
||||
// Dominant-color gradient backdrop seeded from the current track's
|
||||
// album cover. While the color resolves (or when no cover exists),
|
||||
// the gradient collapses to a flat fs.obsidian — same look as
|
||||
// before the polish landed. Tweens to the new color on track change
|
||||
// via AnimatedContainer.
|
||||
final dominantAsync = ref.watch(albumColorProvider(albumId));
|
||||
final dominant = dominantAsync.asData?.value ?? fs.obsidian;
|
||||
// 0.55 alpha keeps the color present without overwhelming contrast
|
||||
// on the title / artist text below. The lower half of the screen
|
||||
// stays obsidian for control legibility.
|
||||
final gradientTop = dominant.withValues(alpha: 0.55);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||
// and the seek slider are still tappable since gesture arena
|
||||
// gives priority to their child gesture detectors.
|
||||
body: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: AnimatedContainer(
|
||||
duration: _trackChangeDuration,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [gradientTop, fs.obsidian],
|
||||
stops: const [0.0, 0.7],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(children: [
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: 280, height: 280, color: fs.slate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(fs: fs),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('cover-${media.id}'),
|
||||
child: _AlbumArt(
|
||||
media: media, albumId: albumId, fs: fs),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('title-${media.id}'),
|
||||
child: _TitleRow(media: media, fs: fs),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
AnimatedSwitcher(
|
||||
duration: _trackChangeDuration,
|
||||
child: Column(
|
||||
key: ValueKey('artist-album-${media.id}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if ((media.album ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
media.album!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SecondaryControls(
|
||||
fs: fs,
|
||||
actions: actions,
|
||||
shuffleOn: shuffleOn,
|
||||
repeatMode: repeatMode,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||
const SizedBox(height: 24),
|
||||
_PrimaryControls(
|
||||
fs: fs,
|
||||
ref: ref,
|
||||
isPlaying: isPlaying,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: '', // not stashed in MediaItem.extras today
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
]),
|
||||
Text(media.artist ?? '', style: TextStyle(color: fs.ash, fontSize: 14)),
|
||||
const SizedBox(height: 16),
|
||||
Slider(
|
||||
activeColor: fs.accent,
|
||||
min: 0,
|
||||
max: dur.inMilliseconds.toDouble().clamp(1, double.infinity),
|
||||
value: pos.inMilliseconds.toDouble().clamp(0, dur.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref.read(audioHandlerProvider).seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 56,
|
||||
icon: Icon(playback?.playing == true ? Icons.pause_circle_filled : Icons.play_circle_filled, color: fs.accent),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 32),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
const Spacer(),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.fs});
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.expand_more, color: fs.parchment),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.queue_music, color: fs.parchment),
|
||||
tooltip: 'Queue',
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumArt extends StatelessWidget {
|
||||
const _AlbumArt({required this.media, required this.albumId, required this.fs});
|
||||
final MediaItem media;
|
||||
final String albumId;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pick the best available source. AlbumCoverCache writes a file://
|
||||
// URI to media.artUri once the cover lands on disk; before then,
|
||||
// fall back to fetching via ServerImage from /api/albums/<id>/cover
|
||||
// (which carries the auth header and falls back gracefully on
|
||||
// failure).
|
||||
Widget cover;
|
||||
final artUri = media.artUri;
|
||||
if (artUri != null && artUri.isScheme('file')) {
|
||||
cover = Image(
|
||||
image: FileImage(File.fromUri(artUri)),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: fs.slate),
|
||||
);
|
||||
} else if (albumId.isNotEmpty) {
|
||||
cover = ServerImage(
|
||||
url: '/api/albums/$albumId/cover',
|
||||
fit: BoxFit.cover,
|
||||
fallback: Container(color: fs.slate),
|
||||
);
|
||||
} else {
|
||||
cover = Container(color: fs.slate);
|
||||
}
|
||||
return AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
||||
child: Hero(
|
||||
tag: kPlayerCoverHeroTag,
|
||||
// flightShuttleBuilder ensures the in-flight Hero renders the
|
||||
// destination's cover image (not the mini bar's small one)
|
||||
// during the entire animation, which reads as a smooth grow
|
||||
// rather than a swap mid-flight.
|
||||
flightShuttleBuilder:
|
||||
(_, __, ___, ____, toHeroContext) => toHeroContext.widget,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TitleRow extends StatelessWidget {
|
||||
const _TitleRow({required this.media, required this.fs});
|
||||
final MediaItem media;
|
||||
final FabledSwordTheme fs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Title-only — like + kebab moved into _SecondaryControls above
|
||||
// the seek bar so they share the same row as shuffle/repeat/queue.
|
||||
return Text(
|
||||
media.title,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 22),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends StatelessWidget {
|
||||
const _SeekRow({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Column(children: [
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryControls extends StatelessWidget {
|
||||
const _PrimaryControls({
|
||||
required this.fs,
|
||||
required this.ref,
|
||||
required this.isPlaying,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final WidgetRef ref;
|
||||
final bool isPlaying;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 72,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action row sitting just above the seek bar. Holds shuffle / repeat
|
||||
/// / queue plus the like + kebab that used to live in the title row,
|
||||
/// so the title can sit truly centered above and this row carries
|
||||
/// every per-track action in one place.
|
||||
class _SecondaryControls extends StatelessWidget {
|
||||
const _SecondaryControls({
|
||||
required this.fs,
|
||||
required this.actions,
|
||||
required this.shuffleOn,
|
||||
required this.repeatMode,
|
||||
required this.media,
|
||||
});
|
||||
final FabledSwordTheme fs;
|
||||
final PlayerActions actions;
|
||||
final bool shuffleOn;
|
||||
final AudioServiceRepeatMode repeatMode;
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
),
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, color: fs.ash),
|
||||
onPressed: () => GoRouter.of(context).push('/queue'),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
||||
TrackActionsButton(
|
||||
track: TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
),
|
||||
hideQueueActions: true,
|
||||
// /now-playing lives outside the ShellRoute. Pushing
|
||||
// /artists/:id or /albums/:id (both shell-children) on top
|
||||
// of it would make go_router try to mount a duplicate
|
||||
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
|
||||
// we hit before with /queue). Await our own pop fully
|
||||
// before pushing the destination so go_router never sees
|
||||
// both pages active in the same frame.
|
||||
onNavigate: (path) async {
|
||||
await Navigator.of(context).maybePop();
|
||||
if (context.mounted) GoRouter.of(context).push(path);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,313 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../api/endpoints/likes.dart' show LikeKind;
|
||||
import '../likes/like_button.dart';
|
||||
import '../models/track.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'now_playing_screen.dart' show kPlayerCoverHeroTag;
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||
/// view only — the heavyweight shuffle/repeat/queue/volume controls
|
||||
/// live in NowPlayingScreen, accessible by tap or swipe-up.
|
||||
///
|
||||
/// ┌─────────────────────────────────────┬──────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
||||
/// │ Artist │ │
|
||||
/// ├─────────────────────────────────────────────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └─────────────────────────────────────────────────┘
|
||||
///
|
||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||
/// the drag-down dismissal on the full screen.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mediaItem = ref.watch(mediaItemProvider).value;
|
||||
final media = ref.watch(mediaItemProvider).value;
|
||||
final playback = ref.watch(playbackStateProvider).value;
|
||||
if (mediaItem == null) return const SizedBox.shrink();
|
||||
if (media == null) return const SizedBox.shrink();
|
||||
|
||||
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||
// only updates on event transitions and would leave it frozen.
|
||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
||||
final dur = media.duration ?? Duration.zero;
|
||||
|
||||
return Material(
|
||||
color: fs.iron,
|
||||
child: InkWell(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/now-playing'),
|
||||
// Swipe up anywhere on the bar to expand into the full player.
|
||||
// We only act on a clear upward flick (>200 px/s) so a slow
|
||||
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
||||
onVerticalDragEnd: (d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -200) context.push('/now-playing');
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(children: [
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(mediaItem.title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14)),
|
||||
Text(mediaItem.artist ?? '', maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
],
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(playback?.playing == true ? Icons.pause : Icons.play_arrow, color: fs.parchment),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (playback?.playing == true) { h.pause(); } else { h.play(); }
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _TrackInfo(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SeekRow(position: pos, duration: dur),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfo extends StatelessWidget {
|
||||
const _TrackInfo({required this.media});
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
// Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so
|
||||
// tapping the mini bar to expand into NowPlayingScreen animates the
|
||||
// artwork from this 48dp footprint to the full-screen size rather
|
||||
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
||||
// so the transition works regardless of what's playing.
|
||||
Widget cover;
|
||||
if (media.artUri != null) {
|
||||
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
||||
// hits the same disk cache the rest of the UI uses (ServerImage,
|
||||
// discover thumbnails). FileImage stays for the file:// branch
|
||||
// populated by AlbumCoverCache — bytes are already on disk and a
|
||||
// second cache layer would only burn duplicate space.
|
||||
cover = Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: CachedNetworkImageProvider(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
// Without a fit, Image paints the source at its intrinsic
|
||||
// resolution inside the 48dp box — a thumbnail-sized cover
|
||||
// would render as a tiny inset. Cover stretches/crops to fill
|
||||
// the box uniformly.
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
);
|
||||
} else {
|
||||
cover = Container(width: 48, height: 48, color: fs.slate);
|
||||
}
|
||||
return Row(
|
||||
// Default centering vertically aligns the like/kebab buttons
|
||||
// against the album art (48dp) — visually they span the title +
|
||||
// artist block instead of sitting on the title baseline.
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Hero(
|
||||
tag: kPlayerCoverHeroTag,
|
||||
child: cover,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Like + kebab as siblings to the title/artist column rather
|
||||
// than nested inside the title row. Width stays 32dp each so
|
||||
// horizontal footprint matches what we had; height stretches
|
||||
// to the row's full 48dp so the icons sit at vertical center
|
||||
// against the title+artist block.
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: LikeButton(
|
||||
kind: LikeKind.track,
|
||||
id: media.id,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayControls extends StatelessWidget {
|
||||
const _PlayControls({required this.playback, required this.ref});
|
||||
final PlaybackState? playback;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 32,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekRow extends ConsumerWidget {
|
||||
const _SeekRow({required this.position, required this.duration});
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.remainder(60).toString();
|
||||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final maxMs =
|
||||
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(position),
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: maxMs,
|
||||
value: position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
||||
onChanged: (v) => ref
|
||||
.read(audioHandlerProvider)
|
||||
.seek(Duration(milliseconds: v.toInt())),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: Text(
|
||||
_fmt(duration),
|
||||
style: TextStyle(color: fs.ash, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
||||
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
||||
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
||||
/// audio_handler stashed at queue-build time.
|
||||
TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
||||
id: media.id,
|
||||
title: media.title,
|
||||
albumId: (media.extras?['album_id'] as String?) ?? '',
|
||||
albumTitle: media.album ?? '',
|
||||
// artist_id is stashed in extras by audio_handler — without it
|
||||
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
||||
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
||||
artistName: media.artist ?? '',
|
||||
durationSec: media.duration?.inSeconds ?? 0,
|
||||
streamUrl: '',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/radio.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../cache/audio_cache_manager.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
@@ -30,6 +31,23 @@ final queueProvider = StreamProvider<List<MediaItem>>(
|
||||
(ref) => ref.watch(audioHandlerProvider).queue,
|
||||
);
|
||||
|
||||
/// Player volume in [0.0, 1.0]. Mirrors just_audio's player.volume;
|
||||
/// modify via PlayerActions.setVolume(). Surfaced separately from
|
||||
/// PlaybackState because audio_service's PlaybackState doesn't carry
|
||||
/// volume — it's a player-side concern.
|
||||
final volumeProvider = StreamProvider<double>(
|
||||
(ref) => ref.watch(audioHandlerProvider).volumeStream,
|
||||
);
|
||||
|
||||
/// Live playback position. just_audio emits at ~200ms cadence so seek
|
||||
/// bars driven by this provider scrub smoothly. Don't use
|
||||
/// PlaybackState.updatePosition for that — it only changes on state
|
||||
/// transitions (play/pause/buffer/seek) and the bar would appear
|
||||
/// frozen between events.
|
||||
final positionProvider = StreamProvider<Duration>(
|
||||
(ref) => ref.watch(audioHandlerProvider).positionStream,
|
||||
);
|
||||
|
||||
class PlayerActions {
|
||||
PlayerActions(this._ref);
|
||||
final Ref _ref;
|
||||
@@ -59,6 +77,41 @@ class PlayerActions {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
await h.enqueue(track);
|
||||
}
|
||||
|
||||
/// Toggles shuffle on/off. Pre-existing audio_service convention.
|
||||
Future<void> toggleShuffle() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = state.shuffleMode == AudioServiceShuffleMode.none
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none;
|
||||
await h.setShuffleMode(next);
|
||||
}
|
||||
|
||||
/// Cycles repeat mode: none → all → one → none.
|
||||
Future<void> cycleRepeat() async {
|
||||
final h = _ref.read(audioHandlerProvider);
|
||||
final state = await h.playbackState.first;
|
||||
final next = switch (state.repeatMode) {
|
||||
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
|
||||
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
|
||||
_ => AudioServiceRepeatMode.none,
|
||||
};
|
||||
await h.setRepeatMode(next);
|
||||
}
|
||||
|
||||
Future<void> setVolume(double v) async {
|
||||
await _ref.read(audioHandlerProvider).setVolume(v);
|
||||
}
|
||||
|
||||
/// Fetches `/api/radio?seed_track=<id>` and starts playing the
|
||||
/// returned track list (seed at index 0 + recommended picks).
|
||||
Future<void> startRadio(String trackId) async {
|
||||
final dio = await _ref.read(dioProvider.future);
|
||||
final tracks = await RadioApi(dio).seedTrack(trackId);
|
||||
if (tracks.isEmpty) return;
|
||||
await playTracks(tracks);
|
||||
}
|
||||
}
|
||||
|
||||
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||
|
||||
@@ -7,18 +7,49 @@ import '../cache/db.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../models/track.dart';
|
||||
import '../player/player_provider.dart';
|
||||
import '../shared/live_events_provider.dart';
|
||||
import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'playlists_provider.dart';
|
||||
|
||||
class PlaylistDetailScreen extends ConsumerWidget {
|
||||
const PlaylistDetailScreen({required this.id, super.key});
|
||||
const PlaylistDetailScreen({required this.id, this.seed, super.key});
|
||||
final String id;
|
||||
|
||||
/// Optional Playlist passed via go_router extra so the header
|
||||
/// (title, cover, track count, play/download CTAs) can render
|
||||
/// before the full detail fetch resolves. Same pattern as album
|
||||
/// + artist nav hydration.
|
||||
final Playlist? seed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// #402 wire-up: invalidate the detail provider on playlist.updated /
|
||||
// .tracks_changed events whose payload matches the visible id. On
|
||||
// .deleted matching this id, navigate back so the user isn't left
|
||||
// staring at a gone-from-server playlist.
|
||||
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||
final e = next.asData?.value;
|
||||
if (e == null) return;
|
||||
final eventPlaylistId = e.data['playlist_id'] as String?;
|
||||
if (eventPlaylistId != id) return;
|
||||
switch (e.kind) {
|
||||
case 'playlist.updated':
|
||||
case 'playlist.tracks_changed':
|
||||
ref.invalidate(playlistDetailProvider(id));
|
||||
case 'playlist.deleted':
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
});
|
||||
final detail = ref.watch(playlistDetailProvider(id));
|
||||
|
||||
// Resolve the best playlist info available for the AppBar title.
|
||||
final livePlaylist = detail.value?.playlist;
|
||||
final headerName = (livePlaylist != null && livePlaylist.name.isNotEmpty)
|
||||
? livePlaylist.name
|
||||
: (seed?.name ?? '');
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
@@ -28,27 +59,118 @@ class PlaylistDetailScreen extends ConsumerWidget {
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: detail.maybeWhen(
|
||||
data: (d) => Text(
|
||||
d.playlist.name,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
title: headerName.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
headerName,
|
||||
style: TextStyle(color: fs.parchment),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// AnimatedSwitcher between the skeleton body and the real body
|
||||
// smooths the cold-visit moment when bulk detail lands. 220ms
|
||||
// matches the per-tile reveal feel used on home / liked tabs.
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
switchInCurve: Curves.easeOut,
|
||||
child: detail.when(
|
||||
loading: () => _SkeletonBody(
|
||||
key: const ValueKey('skeleton'),
|
||||
seed: seed,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
error: (e, _) => Center(
|
||||
key: const ValueKey('error'),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
data: (d) => _Body(key: const ValueKey('body'), detail: d),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
||||
data: (d) => _Body(detail: d),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cold-visit body: header from the seed (if any) + N skeleton rows.
|
||||
/// N comes from the seed's trackCount so the row count matches the
|
||||
/// real list when it lands — no layout jump on swap. Without a seed
|
||||
/// (deep link straight to a playlist with no prior cache) the
|
||||
/// skeleton renders a small default and grows when real data arrives.
|
||||
class _SkeletonBody extends StatelessWidget {
|
||||
const _SkeletonBody({super.key, this.seed});
|
||||
final Playlist? seed;
|
||||
|
||||
static const _defaultSkeletonCount = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final count = seed?.trackCount ?? _defaultSkeletonCount;
|
||||
return ListView.builder(
|
||||
itemCount: count + 1, // +1 for header
|
||||
itemBuilder: (ctx, i) {
|
||||
if (i == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (seed != null && seed!.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
seed!.description,
|
||||
style: TextStyle(color: fs.ash, fontSize: 13),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
seed == null
|
||||
? 'Loading…'
|
||||
: '${seed!.trackCount} ${seed!.trackCount == 1 ? "track" : "tracks"}',
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const _PlaylistTrackSkeleton();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton matched to _PlaylistTrackRow's layout (no cover image —
|
||||
/// playlist rows are text-only). Two text-shaped placeholders for
|
||||
/// title + secondary line, plus a duration block on the right.
|
||||
class _PlaylistTrackSkeleton extends StatelessWidget {
|
||||
const _PlaylistTrackSkeleton();
|
||||
|
||||
@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: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 200, height: 14, color: fs.slate),
|
||||
const SizedBox(height: 6),
|
||||
Container(width: 140, height: 12, color: fs.slate),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Container(width: 32, height: 12, color: fs.slate),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends ConsumerWidget {
|
||||
const _Body({required this.detail});
|
||||
const _Body({super.key, required this.detail});
|
||||
final PlaylistDetail detail;
|
||||
|
||||
@override
|
||||
@@ -179,51 +301,69 @@ class _Header extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistTrackRow extends StatelessWidget {
|
||||
class _PlaylistTrackRow extends ConsumerWidget {
|
||||
const _PlaylistTrackRow({required this.row, required this.onTap});
|
||||
final PlaylistTrack row;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final mins = (row.durationSec ~/ 60).toString().padLeft(2, '0');
|
||||
final secs = (row.durationSec % 60).toString().padLeft(2, '0');
|
||||
final color = row.isAvailable ? fs.parchment : fs.ash;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 14,
|
||||
decoration: row.isAvailable
|
||||
? null
|
||||
: TextDecoration.lineThrough,
|
||||
// "Now playing" highlight — matches _QueueRow and TrackRow so the
|
||||
// user sees which playlist row is current without reading the
|
||||
// player bar. Unavailable rows never match.
|
||||
final currentId = ref.watch(mediaItemProvider).value?.id;
|
||||
final isCurrent = row.isAvailable &&
|
||||
currentId != null &&
|
||||
currentId == row.trackId;
|
||||
final baseColor = row.isAvailable ? fs.parchment : fs.ash;
|
||||
final titleColor = isCurrent ? fs.accent : baseColor;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? fs.iron : null,
|
||||
border: isCurrent
|
||||
? Border(left: BorderSide(color: fs.accent, width: 2))
|
||||
: null,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
row.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: titleColor,
|
||||
fontSize: 14,
|
||||
fontWeight:
|
||||
isCurrent ? FontWeight.w500 : FontWeight.w400,
|
||||
decoration: row.isAvailable
|
||||
? null
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'${row.artistName} · ${row.albumTitle}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (row.trackId != null)
|
||||
TrackActionsButton(track: _toTrackRef(row)),
|
||||
]),
|
||||
Text('$mins:$secs', style: TextStyle(color: fs.ash, fontSize: 12)),
|
||||
if (row.trackId != null)
|
||||
TrackActionsButton(track: _toTrackRef(row)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class PlaylistsListScreen extends ConsumerWidget {
|
||||
final p = items[i];
|
||||
return _PlaylistTile(
|
||||
playlist: p,
|
||||
onTap: () => ctx.push('/playlists/${p.id}'),
|
||||
onTap: () => ctx.push('/playlists/${p.id}', extra: p),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -83,7 +83,11 @@ class _PlaylistTile extends StatelessWidget {
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash)
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback: Icon(Icons.queue_music, color: fs.ash),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
@@ -36,7 +40,21 @@ final playlistsListProvider =
|
||||
fetchAndPopulate: () async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.list(kind: kind);
|
||||
|
||||
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
||||
// every rebuild, so insertOrReplace alone leaves stale rows in
|
||||
// drift. Tapping one of those stale tiles 404s. Delete every
|
||||
// owned drift row whose id isn't in the fresh response, then
|
||||
// upsert the fresh set.
|
||||
final freshOwnedIds =
|
||||
fresh.owned.map((p) => p.id).toSet();
|
||||
await db.batch((b) {
|
||||
if (user != null) {
|
||||
b.deleteWhere(db.cachedPlaylists, (t) {
|
||||
return t.userId.equals(user.id) &
|
||||
t.id.isNotIn(freshOwnedIds);
|
||||
});
|
||||
}
|
||||
for (final p in fresh.all) {
|
||||
b.insert(db.cachedPlaylists, p.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
@@ -49,7 +67,7 @@ final playlistsListProvider =
|
||||
if (kind == 'user') return r.systemVariant == null;
|
||||
if (kind == 'system') return r.systemVariant != null;
|
||||
return true; // 'all'
|
||||
});
|
||||
}).toList();
|
||||
final owned = filtered
|
||||
.where((r) => r.userId == user.id)
|
||||
.map((r) => r.toRef())
|
||||
@@ -60,7 +78,14 @@ final playlistsListProvider =
|
||||
.toList();
|
||||
return PlaylistsList(owned: owned, public: pub);
|
||||
},
|
||||
isOnline: () async => (await ref.read(connectivityProvider.future)),
|
||||
isOnline: () async => (await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||
// Aggregate list — server may add system playlists (For-You /
|
||||
// Discover) that the per-user delta sync doesn't always emit.
|
||||
// Stale-while-revalidate ensures the home tile row reflects the
|
||||
// current server state on every screen visit.
|
||||
alwaysRefresh: true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -103,41 +128,180 @@ final playlistDetailProvider =
|
||||
tracks: const [],
|
||||
);
|
||||
|
||||
Future<bool> isOnline() async {
|
||||
try {
|
||||
return await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> fetchAndPopulate() async {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
|
||||
|
||||
// Collect the track + artist + album rows referenced by these
|
||||
// playlist entries. Without writing the cachedTracks rows
|
||||
// themselves, the detail-screen JOIN against cachedTracks
|
||||
// returns null on every row (only the playlist_tracks join
|
||||
// succeeds), so the UI shows a list with empty titles and
|
||||
// unplayable tracks.
|
||||
final tracks = <String, _TrackRefRow>{};
|
||||
final artists = <String, ArtistRefRow>{};
|
||||
final albums = <String, AlbumRefRow>{};
|
||||
for (final t in fresh.tracks) {
|
||||
final tId = t.trackId;
|
||||
if (tId != null && tId.isNotEmpty) {
|
||||
tracks.putIfAbsent(
|
||||
tId,
|
||||
() => _TrackRefRow(
|
||||
id: tId,
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? '',
|
||||
durationSec: t.durationSec,
|
||||
),
|
||||
);
|
||||
}
|
||||
final aId = t.artistId;
|
||||
final aName = t.artistName;
|
||||
if (aId != null && aId.isNotEmpty && aName.isNotEmpty) {
|
||||
artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName));
|
||||
}
|
||||
final lbId = t.albumId;
|
||||
final lbTitle = t.albumTitle;
|
||||
if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) {
|
||||
albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
|
||||
// Wipe + re-insert this playlist's track positions so deletions
|
||||
// propagate. Without the wipe, removed tracks would linger in
|
||||
// drift after the playlist mutated server-side.
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
if (artists.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedArtists,
|
||||
artists.values
|
||||
.map((a) => CachedArtistsCompanion.insert(
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
sortName: a.name,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (tracks.isNotEmpty) {
|
||||
// Insert/update cachedTracks so the detail screen's JOIN
|
||||
// produces real titles + durations. We don't have
|
||||
// track_number / disc_number on the wire (PlaylistTrack
|
||||
// omits them), so they default to 0 — UI doesn't surface
|
||||
// them on this screen so it's fine.
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedTracks,
|
||||
tracks.values
|
||||
.map((t) => CachedTracksCompanion.insert(
|
||||
id: t.id,
|
||||
albumId: t.albumId,
|
||||
artistId: t.artistId,
|
||||
title: t.title,
|
||||
durationMs: drift.Value(t.durationSec * 1000),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
if (albums.isNotEmpty) {
|
||||
b.insertAllOnConflictUpdate(
|
||||
db.cachedAlbums,
|
||||
albums.values
|
||||
.map((a) => CachedAlbumsCompanion.insert(
|
||||
id: a.id,
|
||||
artistId: a.artistId,
|
||||
title: a.title,
|
||||
sortTitle: a.title,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
||||
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
|
||||
// tiles can outlive the actual server-side playlist). Wipe the
|
||||
// stale row + its track positions so the home tile disappears
|
||||
// on next render and we don't keep trying.
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
await db.batch((b) {
|
||||
b.deleteWhere(
|
||||
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
||||
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Once-per-subscription guard so an empty server response doesn't
|
||||
// cause repeated re-fetches.
|
||||
var fetchAttempted = false;
|
||||
|
||||
await for (final playlistRows in playlistQuery.watch()) {
|
||||
if (playlistRows.isEmpty) {
|
||||
if (await ref.read(connectivityProvider.future)) {
|
||||
try {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final fresh = await api.get(id);
|
||||
await db.batch((b) {
|
||||
b.insert(db.cachedPlaylists, fresh.playlist.toDrift(),
|
||||
mode: drift.InsertMode.insertOrReplace);
|
||||
for (var i = 0; i < fresh.tracks.length; i++) {
|
||||
final t = fresh.tracks[i];
|
||||
if (t.trackId == null) continue;
|
||||
b.insert(
|
||||
db.cachedPlaylistTracks,
|
||||
CachedPlaylistTracksCompanion.insert(
|
||||
playlistId: id,
|
||||
trackId: t.trackId!,
|
||||
position: drift.Value(i),
|
||||
),
|
||||
mode: drift.InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
// watch() re-emits next iteration with populated row.
|
||||
} catch (_) {
|
||||
yield emptyDetail();
|
||||
}
|
||||
} else {
|
||||
if (fetchAttempted) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
fetchAttempted = true;
|
||||
if (!await isOnline()) {
|
||||
yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
final ok = await fetchAndPopulate();
|
||||
if (!ok) yield emptyDetail();
|
||||
continue;
|
||||
}
|
||||
|
||||
final playlist = playlistRows.first.toRef();
|
||||
final trackRows = await tracksQuery.get();
|
||||
|
||||
// Same pattern as albumProvider: playlist row exists but no tracks
|
||||
// (e.g., playlistsListProvider wrote the row, sync hasn't carried
|
||||
// tracks for system playlists). Trigger the same fetch, drift
|
||||
// watch re-emits with populated tracks.
|
||||
if (trackRows.isEmpty && !fetchAttempted) {
|
||||
fetchAttempted = true;
|
||||
if (await isOnline()) {
|
||||
final ok = await fetchAndPopulate();
|
||||
if (ok) continue; // wait for watch re-emit
|
||||
}
|
||||
}
|
||||
|
||||
final tracks = trackRows.asMap().entries.map((e) {
|
||||
final r = e.value;
|
||||
final track = r.readTableOrNull(db.cachedTracks);
|
||||
@@ -157,9 +321,47 @@ final playlistDetailProvider =
|
||||
}).toList();
|
||||
|
||||
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
||||
|
||||
// No SWR refresh here. The aggregate playlistsListProvider does
|
||||
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
|
||||
// refresh on every visit was multiplying with the prefetcher's
|
||||
// parallel fetches and starving user-initiated playback. Pull-
|
||||
// to-refresh on the detail page invalidates the provider, which
|
||||
// is the right path for an explicit refresh.
|
||||
}
|
||||
});
|
||||
|
||||
/// Lightweight tuples used inside fetchAndPopulate to dedupe artist
|
||||
/// and album rows referenced by playlist tracks before we batch-write
|
||||
/// them to drift.
|
||||
class ArtistRefRow {
|
||||
ArtistRefRow(this.id, this.name);
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class AlbumRefRow {
|
||||
AlbumRefRow(this.id, this.title, this.artistId);
|
||||
final String id;
|
||||
final String title;
|
||||
final String artistId;
|
||||
}
|
||||
|
||||
class _TrackRefRow {
|
||||
_TrackRefRow({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.albumId,
|
||||
required this.artistId,
|
||||
required this.durationSec,
|
||||
});
|
||||
final String id;
|
||||
final String title;
|
||||
final String albumId;
|
||||
final String artistId;
|
||||
final int durationSec;
|
||||
}
|
||||
|
||||
final systemPlaylistsStatusProvider =
|
||||
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
|
||||
@@ -1,64 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../library/widgets/play_circle_button.dart';
|
||||
import '../../models/playlist.dart';
|
||||
import '../../models/track.dart';
|
||||
import '../../player/player_provider.dart';
|
||||
import '../../shared/widgets/server_image.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
import '../playlists_provider.dart';
|
||||
|
||||
/// Mirrors the web PlaylistCard. ~176dp wide, square cover (~144dp),
|
||||
/// name + optional system-variant badge below. Tap pushes
|
||||
/// `/playlists/{id}`.
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
/// `/playlists/{id}`. The bottom-right play button fetches the
|
||||
/// playlist and starts playback from track 0; disabled when the
|
||||
/// playlist has no tracks.
|
||||
class PlaylistCard extends ConsumerWidget {
|
||||
const PlaylistCard({super.key, required this.playlist});
|
||||
|
||||
final Playlist playlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final hasTracks = playlist.trackCount > 0;
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/playlists/${playlist.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(url: playlist.coverUrl, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (playlist.isSystem)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
context.push('/playlists/${playlist.id}', extra: playlist),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// Stack: collage + overlaid play button at bottom-right.
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: 144,
|
||||
height: 144,
|
||||
color: fs.slate,
|
||||
// coverUrl is deterministic per playlist (see
|
||||
// CachedPlaylistAdapter.toRef). When the server's
|
||||
// collage isn't built yet, ServerImage's
|
||||
// errorBuilder shows the queue_music icon over the
|
||||
// slate background.
|
||||
child: playlist.coverUrl.isEmpty
|
||||
? Icon(Icons.queue_music, color: fs.ash, size: 56)
|
||||
: ServerImage(
|
||||
url: playlist.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
fallback:
|
||||
Icon(Icons.queue_music, color: fs.ash, size: 56),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
playlist.systemVariant!.replaceAll('_', ' '),
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
Positioned(
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
child: PlayCircleButton(
|
||||
enabled: hasTracks,
|
||||
onPressed: () => _playPlaylist(ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
if (playlist.isSystem)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.iron,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
playlist.systemVariant!.replaceAll('_', ' '),
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetches the playlist via /api/playlists/{id}, materializes each
|
||||
/// PlaylistTrack into a TrackRef (filtering out unavailable rows
|
||||
/// whose `trackId` is null after a track-delete), and plays from
|
||||
/// index 0. Mirrors the web PlaylistCard's onPlayClick.
|
||||
Future<void> _playPlaylist(WidgetRef ref) async {
|
||||
final api = await ref.read(playlistsApiProvider.future);
|
||||
final detail = await api.get(playlist.id);
|
||||
final refs = <TrackRef>[];
|
||||
for (final t in detail.tracks) {
|
||||
if (t.trackId == null) continue;
|
||||
refs.add(TrackRef(
|
||||
id: t.trackId!,
|
||||
title: t.title,
|
||||
albumId: t.albumId ?? '',
|
||||
albumTitle: t.albumTitle,
|
||||
artistId: t.artistId ?? '',
|
||||
artistName: t.artistName,
|
||||
durationSec: t.durationSec,
|
||||
streamUrl: t.streamUrl ?? '',
|
||||
));
|
||||
}
|
||||
if (refs.isEmpty) return;
|
||||
await ref.read(playerActionsProvider).playTracks(refs, initialIndex: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/me.dart';
|
||||
import '../api/endpoints/quarantine.dart';
|
||||
import '../cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import '../cache/connectivity_provider.dart';
|
||||
import '../cache/db.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/quarantine_mine.dart';
|
||||
import '../models/track.dart';
|
||||
@@ -10,58 +16,166 @@ final quarantineApiProvider = FutureProvider<QuarantineApi>((ref) async {
|
||||
return QuarantineApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Drift-first ("hidden") tab controller. Reads from cached_quarantine_
|
||||
/// mine via a drift watch() subscription set up in build(), so any drift
|
||||
/// mutation (incoming sync, optimistic flag/unflag, SSE-triggered
|
||||
/// refresh) re-emits the list automatically.
|
||||
///
|
||||
/// API surface unchanged from the prior FutureProvider-backed controller
|
||||
/// — call sites still do `ref.read(myQuarantineProvider.notifier).flag()`
|
||||
/// / `.unflag()` / `.isHidden()`. Internal storage moved from in-memory
|
||||
/// AsyncNotifier state to drift so the Hidden tab paints from disk on
|
||||
/// cold open and the quarantine list is queryable offline.
|
||||
class MyQuarantineController extends AsyncNotifier<List<QuarantineMineRow>> {
|
||||
@override
|
||||
Future<List<QuarantineMineRow>> build() async {
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
return MeApi(dio).quarantineMine();
|
||||
final db = ref.watch(appDbProvider);
|
||||
|
||||
// Subscribe to drift first so any mutation (incoming sync, flag/
|
||||
// unflag, etc.) propagates without needing a manual invalidate.
|
||||
// Disposed when the provider rebuilds or the listener detaches.
|
||||
final sub = db.select(db.cachedQuarantineMine).watch().listen((rows) {
|
||||
state = AsyncData(rows.map(_rowToModel).toList());
|
||||
});
|
||||
ref.onDispose(sub.cancel);
|
||||
|
||||
// SWR refresh on every build so the freshest server-side state
|
||||
// overtakes drift in the background. Don't await — UI gets the
|
||||
// cached snapshot first, freshness lands later via the watch().
|
||||
unawaited(_refreshFromServer());
|
||||
|
||||
final initial = await db.select(db.cachedQuarantineMine).get();
|
||||
return initial.map(_rowToModel).toList();
|
||||
}
|
||||
|
||||
/// Hits /api/quarantine/mine and replaces the local table with the
|
||||
/// authoritative server set. Best-effort: offline / 5xx / unresolved
|
||||
/// dependencies (e.g. uninitialised serverUrl in tests) all collapse
|
||||
/// to a no-op — drift stays on the prior cached state.
|
||||
Future<void> _refreshFromServer() async {
|
||||
try {
|
||||
final online = await ref
|
||||
.read(connectivityProvider.future)
|
||||
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
||||
if (!online) return;
|
||||
final dio = await ref.read(dioProvider.future);
|
||||
final fresh = await MeApi(dio).quarantineMine();
|
||||
final db = ref.read(appDbProvider);
|
||||
await db.transaction(() async {
|
||||
// Full replace — quarantine list is small and we want server
|
||||
// unflags from other devices to take effect. Doing this in a
|
||||
// transaction means the watch() sees exactly one emission with
|
||||
// the merged state, not a delete-then-insert flicker.
|
||||
await db.delete(db.cachedQuarantineMine).go();
|
||||
for (final r in fresh) {
|
||||
await db
|
||||
.into(db.cachedQuarantineMine)
|
||||
.insertOnConflictUpdate(_modelToCompanion(r));
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Swallow — cached state stays visible.
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this track is in the caller's quarantine list.
|
||||
bool isHidden(String trackId) =>
|
||||
(state.value ?? const []).any((r) => r.trackId == trackId);
|
||||
|
||||
/// Optimistic flag: prepends a synthetic row, calls server,
|
||||
/// rolls back on error.
|
||||
/// Optimistic flag: inserts the synthetic row into drift (watch fires
|
||||
/// immediately so the UI updates), calls server, rolls back on error.
|
||||
Future<void> flag(TrackRef track, String reason, String notes) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
if (current.any((r) => r.trackId == track.id)) return; // already hidden
|
||||
final synthetic = QuarantineMineRow(
|
||||
final db = ref.read(appDbProvider);
|
||||
final existed = await (db.select(db.cachedQuarantineMine)
|
||||
..where((t) => t.trackId.equals(track.id)))
|
||||
.getSingleOrNull();
|
||||
if (existed != null) return; // already hidden
|
||||
|
||||
final companion = CachedQuarantineMineCompanion.insert(
|
||||
trackId: track.id,
|
||||
reason: reason,
|
||||
notes: notes.isEmpty ? null : notes,
|
||||
notes: notes.isEmpty
|
||||
? const drift.Value.absent()
|
||||
: drift.Value(notes),
|
||||
createdAt: DateTime.now().toUtc().toIso8601String(),
|
||||
trackTitle: track.title,
|
||||
trackDurationMs: track.durationSec * 1000,
|
||||
trackDurationMs: drift.Value(track.durationSec * 1000),
|
||||
albumId: track.albumId,
|
||||
albumTitle: track.albumTitle,
|
||||
artistId: track.artistId,
|
||||
artistName: track.artistName,
|
||||
);
|
||||
state = AsyncData([synthetic, ...current]);
|
||||
await db.into(db.cachedQuarantineMine).insertOnConflictUpdate(companion);
|
||||
|
||||
try {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
await api.flag(track.id, reason, notes: notes);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
// Rollback the optimistic insert; watch() re-emits the prior set.
|
||||
await (db.delete(db.cachedQuarantineMine)
|
||||
..where((t) => t.trackId.equals(track.id)))
|
||||
.go();
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic unflag: removes the row, calls server, rolls back on error.
|
||||
/// Optimistic unflag: removes the drift row, calls server, restores
|
||||
/// on error.
|
||||
Future<void> unflag(String trackId) async {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
final current = state.value ?? const <QuarantineMineRow>[];
|
||||
final removed = current.where((r) => r.trackId == trackId).toList();
|
||||
if (removed.isEmpty) return;
|
||||
state = AsyncData(current.where((r) => r.trackId != trackId).toList());
|
||||
final db = ref.read(appDbProvider);
|
||||
final existing = await (db.select(db.cachedQuarantineMine)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.getSingleOrNull();
|
||||
if (existing == null) return;
|
||||
|
||||
await (db.delete(db.cachedQuarantineMine)
|
||||
..where((t) => t.trackId.equals(trackId)))
|
||||
.go();
|
||||
|
||||
try {
|
||||
final api = await ref.read(quarantineApiProvider.future);
|
||||
await api.unflag(trackId);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
// Restore the row we just deleted — toCompanion(true) preserves
|
||||
// every field including the original fetchedAt timestamp.
|
||||
await db
|
||||
.into(db.cachedQuarantineMine)
|
||||
.insertOnConflictUpdate(existing.toCompanion(true));
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
static QuarantineMineRow _rowToModel(CachedQuarantineMineData r) =>
|
||||
QuarantineMineRow(
|
||||
trackId: r.trackId,
|
||||
reason: r.reason,
|
||||
notes: r.notes,
|
||||
createdAt: r.createdAt,
|
||||
trackTitle: r.trackTitle,
|
||||
trackDurationMs: r.trackDurationMs,
|
||||
albumId: r.albumId,
|
||||
albumTitle: r.albumTitle,
|
||||
albumCoverArtPath: r.albumCoverArtPath,
|
||||
artistId: r.artistId,
|
||||
artistName: r.artistName,
|
||||
);
|
||||
|
||||
static CachedQuarantineMineCompanion _modelToCompanion(QuarantineMineRow r) =>
|
||||
CachedQuarantineMineCompanion.insert(
|
||||
trackId: r.trackId,
|
||||
reason: r.reason,
|
||||
notes: r.notes == null ? const drift.Value.absent() : drift.Value(r.notes),
|
||||
createdAt: r.createdAt,
|
||||
trackTitle: r.trackTitle,
|
||||
trackDurationMs: drift.Value(r.trackDurationMs),
|
||||
albumId: r.albumId,
|
||||
albumTitle: r.albumTitle,
|
||||
albumCoverArtPath: r.albumCoverArtPath == null
|
||||
? const drift.Value.absent()
|
||||
: drift.Value(r.albumCoverArtPath),
|
||||
artistId: r.artistId,
|
||||
artistName: r.artistName,
|
||||
);
|
||||
}
|
||||
|
||||
final myQuarantineProvider =
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/admin_request.dart';
|
||||
import '../shared/live_events_provider.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'requests_provider.dart';
|
||||
@@ -13,6 +14,16 @@ class RequestsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
// #402 wire-up: refresh the user's own requests list on
|
||||
// request.status_changed. Server-side events are user-scoped
|
||||
// (admin actions on someone else's request route to that other
|
||||
// user's stream); the dispatcher's filter already drops mismatches.
|
||||
ref.listen<AsyncValue<LiveEvent>>(liveEventsProvider, (_, next) {
|
||||
final e = next.asData?.value;
|
||||
if (e?.kind == 'request.status_changed') {
|
||||
ref.invalidate(myRequestsProvider);
|
||||
}
|
||||
});
|
||||
final requests = ref.watch(myRequestsProvider);
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Maps incoming LiveEvent kinds to provider invalidations.
|
||||
//
|
||||
// Activated by ref.read(liveEventsDispatcherProvider) in app.dart; once
|
||||
// activated, the dispatcher listens to liveEventsProvider for the
|
||||
// lifetime of the ProviderScope and invalidates the small set of
|
||||
// public-scoped providers we know about.
|
||||
//
|
||||
// Screen-scoped providers (file-private providers in library_screen.dart,
|
||||
// admin handlers, etc.) opt in to live-refresh by themselves listening
|
||||
// to liveEventsProvider — the dispatcher only handles cross-screen,
|
||||
// publicly-importable providers. This keeps the dispatcher small and
|
||||
// avoids a back-edge dependency from /shared onto every feature folder.
|
||||
//
|
||||
// Includes an AppLifecycleState resume handler that defensively
|
||||
// invalidates the same set on app foreground. SSE will catch up on its
|
||||
// own, but on cold-start or after a long background period the
|
||||
// re-invalidate is fast and avoids stale data flashing before the
|
||||
// stream reconnects.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../library/library_providers.dart' show homeProvider;
|
||||
import '../quarantine/quarantine_provider.dart';
|
||||
import 'live_events_provider.dart';
|
||||
|
||||
/// Activates the SSE → invalidation dispatcher for the lifetime of the
|
||||
/// containing ProviderScope. Read this once at startup (from app.dart);
|
||||
/// the provider's body runs once, sets up listeners, and returns a
|
||||
/// sentinel value.
|
||||
final liveEventsDispatcherProvider = Provider<_LiveEventsDispatcher>((ref) {
|
||||
final d = _LiveEventsDispatcher(ref);
|
||||
ref.onDispose(d.dispose);
|
||||
return d;
|
||||
});
|
||||
|
||||
class _LiveEventsDispatcher with WidgetsBindingObserver {
|
||||
_LiveEventsDispatcher(this._ref) {
|
||||
// Subscribe to the event stream. Each emitted event invokes
|
||||
// _handle. Errors / disconnects auto-rebuild the underlying
|
||||
// provider; we don't need to react to them here.
|
||||
_sub = _ref.listen<AsyncValue<LiveEvent>>(
|
||||
liveEventsProvider,
|
||||
(_, next) => next.whenData(_handle),
|
||||
fireImmediately: false,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
late final ProviderSubscription<AsyncValue<LiveEvent>> _sub;
|
||||
|
||||
void _handle(LiveEvent e) {
|
||||
// Map event kinds to provider invalidations. Keep this list short:
|
||||
// only providers reachable from /shared. Screen-private providers
|
||||
// listen to liveEventsProvider themselves.
|
||||
switch (e.kind) {
|
||||
case 'quarantine.flagged':
|
||||
case 'quarantine.unflagged':
|
||||
case 'quarantine.resolved':
|
||||
case 'quarantine.file_deleted':
|
||||
case 'quarantine.deleted_via_lidarr':
|
||||
_ref.invalidate(myQuarantineProvider);
|
||||
// Hidden / liked / album rows referencing a deleted track may
|
||||
// now be stale — homeProvider re-fetch refreshes the cards.
|
||||
_ref.invalidate(homeProvider);
|
||||
case 'playlist.created':
|
||||
case 'playlist.updated':
|
||||
case 'playlist.deleted':
|
||||
case 'playlist.tracks_changed':
|
||||
// Home renders a Playlists row; refresh it so a delete or add
|
||||
// is reflected immediately. Detail screens that need the
|
||||
// mutated row will get a separate invalidate from their own
|
||||
// listener (filed as a follow-up).
|
||||
_ref.invalidate(homeProvider);
|
||||
case 'scan.run_started':
|
||||
case 'scan.run_finished':
|
||||
// Admin scan card provider lives in the admin feature folder
|
||||
// and is file-private today; will be invalidated by the admin
|
||||
// dashboard's own listener once that exists.
|
||||
break;
|
||||
default:
|
||||
// track.liked / track.unliked / request.status_changed reach
|
||||
// screen-private providers; their screens listen directly.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Defensive cold-start invalidation. SSE will catch up on its
|
||||
// own, but the re-invalidate flushes any stale data that
|
||||
// landed while the app was backgrounded.
|
||||
_ref.invalidate(myQuarantineProvider);
|
||||
_ref.invalidate(homeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_sub.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Subscribes to the server's Server-Sent Events stream
|
||||
// (GET /api/events/stream, see Fable #392) and exposes a parsed event
|
||||
// stream as a Riverpod StreamProvider. Consumers wire invalidation
|
||||
// behavior in live_events_dispatcher.dart.
|
||||
//
|
||||
// On disconnect (network blip, server restart, token rotation), the
|
||||
// provider's StreamController completes; the dispatcher's auto-rebuild
|
||||
// when the auth state changes re-subscribes. Explicit exponential
|
||||
// backoff lives at the dispatcher layer so we don't re-create the dio
|
||||
// connection too aggressively.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
|
||||
/// Parsed event from the server's SSE stream. `kind` follows
|
||||
/// "domain.action" naming; `data` carries the payload map.
|
||||
class LiveEvent {
|
||||
const LiveEvent({required this.kind, required this.userId, required this.data});
|
||||
|
||||
final String kind;
|
||||
final String userId;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
@override
|
||||
String toString() => 'LiveEvent($kind, user=$userId, data=$data)';
|
||||
}
|
||||
|
||||
/// Streams parsed LiveEvent values from /api/events/stream. Errors and
|
||||
/// disconnects surface as stream errors; the dispatcher decides whether
|
||||
/// to retry.
|
||||
final liveEventsProvider = StreamProvider<LiveEvent>((ref) async* {
|
||||
// Gate the subscription on having both a server URL and a session
|
||||
// token. If either is missing, emit nothing and let the provider
|
||||
// auto-rebuild when auth state lands.
|
||||
final token = await ref.watch(sessionTokenProvider.future);
|
||||
if (token == null || token.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final dio = await ref.watch(dioProvider.future);
|
||||
|
||||
final controller = StreamController<LiveEvent>();
|
||||
ref.onDispose(controller.close);
|
||||
|
||||
// ignore: unawaited_futures
|
||||
_runSubscription(dio, controller);
|
||||
|
||||
yield* controller.stream;
|
||||
});
|
||||
|
||||
/// Runs the dio streaming request and pushes parsed events into
|
||||
/// [controller]. Closes the controller when the stream ends or errors.
|
||||
Future<void> _runSubscription(Dio dio, StreamController<LiveEvent> controller) async {
|
||||
try {
|
||||
final resp = await dio.get<ResponseBody>(
|
||||
'/api/events/stream',
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
// No timeout — the server emits 15s heartbeats; idle timeouts
|
||||
// on the client side would tear down a healthy connection.
|
||||
receiveTimeout: Duration.zero,
|
||||
headers: const {'Accept': 'text/event-stream'},
|
||||
),
|
||||
);
|
||||
|
||||
// SSE frames are delimited by blank lines. Accumulate raw bytes
|
||||
// into a string buffer; flush parsed events on each "\n\n".
|
||||
var buffer = '';
|
||||
await for (final chunk in resp.data!.stream) {
|
||||
buffer += utf8.decode(chunk, allowMalformed: true);
|
||||
while (true) {
|
||||
final i = buffer.indexOf('\n\n');
|
||||
if (i < 0) break;
|
||||
final frame = buffer.substring(0, i);
|
||||
buffer = buffer.substring(i + 2);
|
||||
final event = _parseFrame(frame);
|
||||
if (event != null && !controller.isClosed) {
|
||||
controller.add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!controller.isClosed) {
|
||||
await controller.close();
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(e, st);
|
||||
await controller.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses one SSE frame. Heartbeat comments (starting with ":") and
|
||||
/// frames without a `data:` line return null. Frames with a `data:`
|
||||
/// payload that doesn't parse as JSON are also dropped (logged at
|
||||
/// debug level by the caller if needed).
|
||||
LiveEvent? _parseFrame(String frame) {
|
||||
String? kind;
|
||||
String? dataLine;
|
||||
for (final line in frame.split('\n')) {
|
||||
if (line.isEmpty || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
kind = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLine = line.substring(5).trim();
|
||||
}
|
||||
}
|
||||
if (dataLine == null) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(dataLine) as Map<String, dynamic>;
|
||||
return LiveEvent(
|
||||
kind: kind ?? (decoded['kind'] as String? ?? ''),
|
||||
userId: decoded['user_id'] as String? ?? '',
|
||||
data: (decoded['data'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import '../auth/login_screen.dart';
|
||||
import '../auth/server_url_screen.dart';
|
||||
import '../library/album_detail_screen.dart';
|
||||
import '../library/artist_detail_screen.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/playlist.dart';
|
||||
import '../discover/discover_screen.dart';
|
||||
import '../library/home_screen.dart';
|
||||
import '../library/library_screen.dart';
|
||||
@@ -18,6 +21,7 @@ import '../playlists/playlists_list_screen.dart';
|
||||
import '../requests/requests_screen.dart';
|
||||
import '../search/search_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../update/client_update_provider.dart';
|
||||
import '../update/update_banner.dart';
|
||||
import '../admin/admin_landing_screen.dart';
|
||||
import '../admin/admin_requests_screen.dart';
|
||||
@@ -54,20 +58,62 @@ GoRouter buildRouter(Ref ref) {
|
||||
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
||||
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
// /now-playing lives outside the ShellRoute on purpose. The full
|
||||
// player IS the player UI when active, and we don't want the mini
|
||||
// bar from the shell underneath fighting for the bottom strip.
|
||||
// Pushing this route unmounts the shell entirely; the slide-up
|
||||
// transition still feels right because the shell stays painted
|
||||
// for the duration of the animation.
|
||||
GoRoute(
|
||||
path: '/now-playing',
|
||||
pageBuilder: (_, __) => CustomTransitionPage(
|
||||
child: const NowPlayingScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 240),
|
||||
transitionsBuilder: (_, anim, __, child) {
|
||||
final eased = CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(eased),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// /queue lives outside the ShellRoute too. Why: pushing /queue
|
||||
// from /now-playing (which is also outside the shell) used to
|
||||
// cause go_router to mount a second ShellRoute instance under
|
||||
// the existing one, producing a duplicate page-key assertion
|
||||
// (NavigatorState._debugCheckDuplicatedPageKeys). Top-level
|
||||
// routes can stack on each other freely; shell-children can't
|
||||
// when something on top of the shell is already routing.
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
||||
routes: [
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/artists/:id',
|
||||
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
||||
// `extra` carries an optional ArtistRef so the detail
|
||||
// header renders immediately while tracks/albums load.
|
||||
builder: (_, s) => ArtistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is ArtistRef ? s.extra as ArtistRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/albums/:id',
|
||||
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => AlbumDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is AlbumRef ? s.extra as AlbumRef : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
||||
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
||||
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
||||
@@ -75,7 +121,10 @@ GoRouter buildRouter(Ref ref) {
|
||||
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
||||
GoRoute(
|
||||
path: '/playlists/:id',
|
||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
||||
builder: (_, s) => PlaylistDetailScreen(
|
||||
id: s.pathParameters['id']!,
|
||||
seed: s.extra is Playlist ? s.extra as Playlist : null,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||
@@ -88,15 +137,35 @@ GoRouter buildRouter(Ref ref) {
|
||||
);
|
||||
}
|
||||
|
||||
class _ShellWithPlayerBar extends StatelessWidget {
|
||||
class _ShellWithPlayerBar extends ConsumerWidget {
|
||||
const _ShellWithPlayerBar({required this.child});
|
||||
final Widget child;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// The banner sits above the routed child and uses SafeArea to clear
|
||||
// the system status bar. MediaQuery.padding is screen-relative
|
||||
// though, so the child's Scaffold/AppBar would still apply its own
|
||||
// top status-bar inset on top of the banner — doubling the gap.
|
||||
// When the banner is showing, strip that inset from the child so
|
||||
// its AppBar lands directly under the banner.
|
||||
final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null;
|
||||
return Column(
|
||||
children: [
|
||||
// VersionTooOldBanner above UpdateBanner: a server-rejects-you
|
||||
// soft warning carries more user-relevant urgency than the APK
|
||||
// download prompt below it. The two banners can coexist
|
||||
// (server says you're too old AND a local APK is queued).
|
||||
const VersionTooOldBanner(),
|
||||
const UpdateBanner(),
|
||||
Expanded(child: child),
|
||||
Expanded(
|
||||
child: hasBanner
|
||||
? MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
),
|
||||
const PlayerBar(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../auth/auth_provider.dart';
|
||||
|
||||
/// Image.network wrapper that resolves server-relative URLs (e.g.
|
||||
/// CachedNetworkImage wrapper that resolves server-relative URLs (e.g.
|
||||
/// `/api/albums/<id>/cover`) against the configured server base URL
|
||||
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
||||
/// unchanged. Empty/null base or empty URL render the [fallback]
|
||||
@@ -12,6 +13,11 @@ import '../../auth/auth_provider.dart';
|
||||
///
|
||||
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
||||
/// stream URLs, so cover art and audio resolve URLs the same way.
|
||||
///
|
||||
/// Uses CachedNetworkImage so cover bytes land on disk (path_provider
|
||||
/// temp dir, keyed by URL) and survive scroll-off + app restart. The
|
||||
/// previous Image.network behavior cached only in-memory, so the cold-
|
||||
/// start home grid re-downloaded every cover.
|
||||
class ServerImage extends ConsumerWidget {
|
||||
const ServerImage({
|
||||
super.key,
|
||||
@@ -31,15 +37,43 @@ class ServerImage extends ConsumerWidget {
|
||||
final base = ref.watch(serverUrlProvider).value;
|
||||
final resolved = _resolve(base, url);
|
||||
if (resolved == null) return empty;
|
||||
// Cover endpoints are gated by RequireUser server-side. Image.network
|
||||
// doesn't carry cookies/Bearer tokens automatically the way the
|
||||
// browser's <img> tag does, so we forward the session token as a
|
||||
// header. Without this, the server returns 401 and the image fails.
|
||||
final token = ref.watch(sessionTokenProvider).value;
|
||||
final headers = (token != null && token.isNotEmpty)
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: null;
|
||||
return Image.network(resolved, fit: fit, headers: headers);
|
||||
// Cover endpoints are gated by RequireUser server-side. The image
|
||||
// loader doesn't carry cookies/Bearer tokens automatically the way
|
||||
// the browser's <img> tag does, so we forward the session token as
|
||||
// a header. Without this, the server returns 401 and the image
|
||||
// fails.
|
||||
//
|
||||
// sessionTokenProvider is a FutureProvider — on first read after a
|
||||
// hot restart its .value is null until the secure-storage read
|
||||
// resolves. If we mounted the image with httpHeaders:null at that
|
||||
// moment, the disk cache would lock in the 401 response and never
|
||||
// retry. Hold the fallback until the token is available, then
|
||||
// mount the image with the auth header attached.
|
||||
final tokenAsync = ref.watch(sessionTokenProvider);
|
||||
return tokenAsync.when(
|
||||
loading: () => empty,
|
||||
error: (_, __) => empty,
|
||||
data: (token) {
|
||||
final headers = (token != null && token.isNotEmpty)
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: <String, String>{};
|
||||
return CachedNetworkImage(
|
||||
imageUrl: resolved,
|
||||
httpHeaders: headers,
|
||||
fit: fit,
|
||||
// 120ms feels like cover bytes settling in on a cache miss
|
||||
// (smoother than the abrupt zero-fade); on cache hits the
|
||||
// image is decoded synchronously so the fade is imperceptible.
|
||||
// The default 500ms is too long — looks like a regression on
|
||||
// a populated grid.
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
fadeOutDuration: Duration.zero,
|
||||
// Keep failures local — a single 401/timeout shouldn't dump
|
||||
// a stack trace or replace the parent Container's background.
|
||||
errorWidget: (_, __, ___) => empty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String? _resolve(String? baseUrl, String url) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Skeleton placeholder widgets for the per-item rendering architecture
|
||||
// (see docs/superpowers/specs/2026-05-13-per-item-rendering-design.md).
|
||||
//
|
||||
// Each skeleton matches the exact dimensions of its corresponding real
|
||||
// card so the layout doesn't shift when content lands. A subtle
|
||||
// shimmer sweep makes the page feel alive while individual tiles
|
||||
// hydrate against drift / REST in the background.
|
||||
//
|
||||
// Self-contained shimmer (no `shimmer` package dep) so we can keep
|
||||
// pubspec lean and tune the sweep colors against FabledSword tokens.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
/// Period of one full shimmer sweep across the skeleton.
|
||||
const Duration _shimmerPeriod = Duration(milliseconds: 1200);
|
||||
|
||||
/// Wraps a child with a slow-moving highlight band on top of the
|
||||
/// skeleton's base color. Cheap — uses a single AnimationController
|
||||
/// per surface and a LinearGradient shader.
|
||||
class _Shimmer extends StatefulWidget {
|
||||
const _Shimmer({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_Shimmer> createState() => _ShimmerState();
|
||||
}
|
||||
|
||||
class _ShimmerState extends State<_Shimmer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: _shimmerPeriod,
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, child) {
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcATop,
|
||||
shaderCallback: (rect) {
|
||||
// Sweep starts off-screen left, ends off-screen right.
|
||||
final dx = (_ctrl.value * 2 - 1) * rect.width;
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
fs.slate,
|
||||
fs.iron,
|
||||
fs.slate,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
transform: _SlideGradient(dx),
|
||||
).createShader(rect);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for translating a gradient horizontally inside its rect.
|
||||
class _SlideGradient extends GradientTransform {
|
||||
const _SlideGradient(this.dx);
|
||||
final double dx;
|
||||
|
||||
@override
|
||||
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
|
||||
return Matrix4.translationValues(dx, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton matched to AlbumCard's 140px outer width / 124px cover /
|
||||
/// title + artist text rows. Used in horizontal carousels where the
|
||||
/// real card lives.
|
||||
class SkeletonAlbumTile extends StatelessWidget {
|
||||
const SkeletonAlbumTile({super.key, this.width = 140});
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final coverSize = width - 16;
|
||||
return _Shimmer(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: fs.slate,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(width: coverSize * 0.8, height: 14, color: fs.slate),
|
||||
const SizedBox(height: 4),
|
||||
Container(width: coverSize * 0.6, height: 12, color: fs.slate),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton matched to ArtistCard's 140px / 124px round-thumb shape.
|
||||
class SkeletonArtistTile extends StatelessWidget {
|
||||
const SkeletonArtistTile({super.key, this.width = 140});
|
||||
final double width;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final coverSize = width - 16;
|
||||
return _Shimmer(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.slate,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(width: coverSize * 0.7, height: 14, color: fs.slate),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton matched to TrackRow's vertical-list layout. 56dp cover +
|
||||
/// title + artist line, similar to the real row.
|
||||
class SkeletonTrackRow extends StatelessWidget {
|
||||
const SkeletonTrackRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return _Shimmer(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: fs.slate,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 180, height: 14, color: fs.slate),
|
||||
const SizedBox(height: 6),
|
||||
Container(width: 120, height: 12, color: fs.slate),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class TrackActionsButton extends StatelessWidget {
|
||||
super.key,
|
||||
required this.track,
|
||||
this.hideQueueActions = false,
|
||||
this.onNavigate,
|
||||
});
|
||||
|
||||
final TrackRef track;
|
||||
@@ -19,6 +20,11 @@ class TrackActionsButton extends StatelessWidget {
|
||||
/// screen where the menu's track IS the currently-playing one.
|
||||
final bool hideQueueActions;
|
||||
|
||||
/// Forwarded to TrackActionsSheet.onNavigate. The host receives the
|
||||
/// destination path AFTER the sheet has popped and is responsible
|
||||
/// for navigating to it (typically: pop self first, then push).
|
||||
final Future<void> Function(String path)? onNavigate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
@@ -29,6 +35,7 @@ class TrackActionsButton extends StatelessWidget {
|
||||
context,
|
||||
track,
|
||||
hideQueueActions: hideQueueActions,
|
||||
onNavigate: onNavigate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,15 +21,27 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.track,
|
||||
required this.hideQueueActions,
|
||||
this.onNavigate,
|
||||
});
|
||||
|
||||
final TrackRef track;
|
||||
final bool hideQueueActions;
|
||||
|
||||
/// Optional hook for "Go to album" / "Go to artist". Called with
|
||||
/// the destination route path AFTER the sheet has popped. Lets
|
||||
/// callers that live OUTSIDE the ShellRoute (e.g. the full player
|
||||
/// at /now-playing) pop themselves first then push, so go_router
|
||||
/// doesn't try to mount a second ShellRoute on top of the active
|
||||
/// top-level route. When null, the sheet does its own
|
||||
/// context.push, which is correct for surfaces already inside the
|
||||
/// shell (mini player, track rows on detail screens, etc.).
|
||||
final Future<void> Function(String path)? onNavigate;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context,
|
||||
TrackRef track, {
|
||||
bool hideQueueActions = false,
|
||||
Future<void> Function(String path)? onNavigate,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -37,6 +49,7 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
builder: (_) => TrackActionsSheet(
|
||||
track: track,
|
||||
hideQueueActions: hideQueueActions,
|
||||
onNavigate: onNavigate,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -117,6 +130,23 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_start_radio'),
|
||||
icon: Icons.radio,
|
||||
label: 'Start radio',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
try {
|
||||
await ref.read(playerActionsProvider).startRadio(track.id);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Couldn't start radio: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
const _Divider(),
|
||||
_MenuItem(
|
||||
key: const Key('track_actions_go_to_album'),
|
||||
@@ -124,7 +154,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
label: 'Go to album',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/albums/${track.albumId}');
|
||||
final path = '/albums/${track.albumId}';
|
||||
if (onNavigate != null) {
|
||||
onNavigate!(path);
|
||||
} else {
|
||||
context.push(path);
|
||||
}
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
@@ -133,7 +168,12 @@ class TrackActionsSheet extends ConsumerWidget {
|
||||
label: 'Go to artist',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push('/artists/${track.artistId}');
|
||||
final path = '/artists/${track.artistId}';
|
||||
if (onNavigate != null) {
|
||||
onNavigate!(path);
|
||||
} else {
|
||||
context.push(path);
|
||||
}
|
||||
},
|
||||
),
|
||||
const _Divider(),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -6,61 +9,255 @@ import 'package:pub_semver/pub_semver.dart';
|
||||
import '../../api/client.dart';
|
||||
import '../../api/endpoints/health.dart';
|
||||
import '../../auth/auth_provider.dart';
|
||||
import '../../theme/theme_extension.dart';
|
||||
|
||||
final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async {
|
||||
final url = await ref.watch(serverUrlProvider.future);
|
||||
if (url == null) return _VersionResult.skipped;
|
||||
final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null);
|
||||
final body = await HealthApi(dio).check();
|
||||
final min = body['min_client_version'];
|
||||
if (min == null || min.isEmpty) return _VersionResult.skipped;
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final mine = Version.parse(info.version);
|
||||
final required = Version.parse(min);
|
||||
return mine < required ? _VersionResult.tooOld : _VersionResult.ok;
|
||||
});
|
||||
/// Result of the most recent /healthz version compatibility check.
|
||||
///
|
||||
/// `skipped` means the server didn't emit a `min_client_version` field
|
||||
/// (older servers, partial deploys). Treat as compatible.
|
||||
enum VersionResult { ok, tooOld, skipped }
|
||||
|
||||
enum _VersionResult { ok, tooOld, skipped }
|
||||
VersionResult _resultFromString(String? s) {
|
||||
switch (s) {
|
||||
case 'ok':
|
||||
return VersionResult.ok;
|
||||
case 'tooOld':
|
||||
return VersionResult.tooOld;
|
||||
default:
|
||||
return VersionResult.skipped;
|
||||
}
|
||||
}
|
||||
|
||||
class VersionGate extends ConsumerWidget {
|
||||
String _stringFromResult(VersionResult r) {
|
||||
switch (r) {
|
||||
case VersionResult.ok:
|
||||
return 'ok';
|
||||
case VersionResult.tooOld:
|
||||
return 'tooOld';
|
||||
case VersionResult.skipped:
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the version-compatibility check against /healthz. Non-blocking:
|
||||
/// the controller hydrates from a 1h-throttled cache on boot, exposes
|
||||
/// the current `VersionResult`, and refreshes itself in the background.
|
||||
/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide
|
||||
/// whether to surface the "too old" banner — never blocks rendering.
|
||||
///
|
||||
/// Cache keys live in flutter_secure_storage so the cadence survives
|
||||
/// app restarts. 1h throttle bounds /healthz traffic; explicit
|
||||
/// `recheck()` (from the banner's "Check now" button or app resume)
|
||||
/// bypasses the throttle.
|
||||
class VersionCheckController extends AsyncNotifier<VersionResult> {
|
||||
static const _kResult = 'version_check_result';
|
||||
static const _kAtMs = 'version_check_at_ms';
|
||||
// 1 minute. The /healthz response is sub-1KB and the call is
|
||||
// non-blocking, so a tight cadence buys faster recovery from
|
||||
// server-side min_client_version bumps without measurable cost. The
|
||||
// gate also acts as a safety net against duplicate calls when the
|
||||
// periodic timer and a resume event fire close together.
|
||||
static const _staleMs = 60 * 1000;
|
||||
|
||||
@override
|
||||
Future<VersionResult> build() async {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final resultStr = await storage.read(key: _kResult);
|
||||
final atStr = await storage.read(key: _kAtMs);
|
||||
final cached = _resultFromString(resultStr);
|
||||
final at = int.tryParse(atStr ?? '') ?? 0;
|
||||
final nowMs = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Fire background recheck on cold-start when cache is missing or stale.
|
||||
// Doesn't block: build returns the cached result immediately; the
|
||||
// recheck updates state asynchronously when it lands.
|
||||
if (nowMs - at > _staleMs) {
|
||||
Future.microtask(_runCheck);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/// Force a fresh check, bypassing the 1h staleness gate. Used by the
|
||||
/// "Check now" banner button and (indirectly) by the AppLifecycleState
|
||||
/// resume observer in VersionGate.
|
||||
Future<void> recheck() async => _runCheck();
|
||||
|
||||
/// Recheck only if the cache is older than the 1h throttle. No-op
|
||||
/// when fresh. Used on app resume to avoid hammering /healthz when
|
||||
/// the user is briefly switching between apps.
|
||||
Future<void> recheckIfStale() async {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final atStr = await storage.read(key: _kAtMs);
|
||||
final at = int.tryParse(atStr ?? '') ?? 0;
|
||||
if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) {
|
||||
await _runCheck();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runCheck() async {
|
||||
try {
|
||||
final url = await ref.read(serverUrlProvider.future);
|
||||
if (url == null || url.isEmpty) return;
|
||||
// Bounded timeouts for a health probe — the default
|
||||
// ApiClient.buildDio dio is tuned for actual data fetches
|
||||
// (8s connect + 30s receive). /healthz should resolve in
|
||||
// tens of milliseconds; failing fast unblocks slow networks.
|
||||
final dio = ApiClient.buildDio(
|
||||
baseUrl: url,
|
||||
tokenResolver: () async => null,
|
||||
);
|
||||
dio.options.connectTimeout = const Duration(seconds: 3);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 2);
|
||||
final body = await HealthApi(dio).check();
|
||||
final min = body['min_client_version'];
|
||||
VersionResult result;
|
||||
if (min == null || min.isEmpty) {
|
||||
result = VersionResult.skipped;
|
||||
} else {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final mine = Version.parse(info.version);
|
||||
final required = Version.parse(min);
|
||||
result = mine < required ? VersionResult.tooOld : VersionResult.ok;
|
||||
}
|
||||
// Persist + flip state. Storage write before state assignment so
|
||||
// a hot reload immediately after won't see a fresh state with a
|
||||
// stale cache.
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
await storage.write(key: _kResult, value: _stringFromResult(result));
|
||||
await storage.write(
|
||||
key: _kAtMs,
|
||||
value: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
);
|
||||
state = AsyncData(result);
|
||||
} on DioException catch (_) {
|
||||
// Network error / timeout. Keep the cached state; don't bump the
|
||||
// timestamp so the next staleness check still triggers a retry.
|
||||
} catch (_) {
|
||||
// Other errors (PackageInfo, parsing, etc.). Same handling —
|
||||
// soft-fail so the UI never sees an error state from a defensive
|
||||
// background check.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final versionCheckProvider =
|
||||
AsyncNotifierProvider<VersionCheckController, VersionResult>(
|
||||
VersionCheckController.new,
|
||||
);
|
||||
|
||||
/// Wraps the shell. Non-blocking: always renders the child. Activates
|
||||
/// the version check controller on mount and reruns it on app resume
|
||||
/// (gated by the controller's 1h staleness throttle).
|
||||
class VersionGate extends ConsumerStatefulWidget {
|
||||
const VersionGate({required this.child, super.key});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ref.watch(_versionCheckProvider).when(
|
||||
data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child,
|
||||
error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error
|
||||
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
);
|
||||
}
|
||||
ConsumerState<VersionGate> createState() => _VersionGateState();
|
||||
}
|
||||
|
||||
class _TooOldScreen extends StatelessWidget {
|
||||
const _TooOldScreen();
|
||||
class _VersionGateState extends ConsumerState<VersionGate>
|
||||
with WidgetsBindingObserver {
|
||||
// Polling interval during active foreground use. Paired with the
|
||||
// controller's 1m staleness gate so concurrent fires (timer + resume)
|
||||
// dedupe to a single network call. Tight cadence is affordable —
|
||||
// /healthz is sub-1KB and the call is non-blocking.
|
||||
static const _pollInterval = Duration(minutes: 1);
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Update required',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'This client is too old for that Minstrel server. Install the latest build to continue.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Read once so the provider's build() runs; the controller
|
||||
// hydrates from cache + fires a background check when stale.
|
||||
ref.read(versionCheckProvider);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopPolling();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(_pollInterval, (_) {
|
||||
// ignore: unawaited_futures
|
||||
ref.read(versionCheckProvider.notifier).recheckIfStale();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
// Re-arm the timer + fire one immediate (staleness-gated) check
|
||||
// so users who foreground after a long away period get a fresh
|
||||
// result without waiting for the next tick.
|
||||
// ignore: unawaited_futures
|
||||
ref.read(versionCheckProvider.notifier).recheckIfStale();
|
||||
_startPolling();
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.detached:
|
||||
// Stop firing while backgrounded — no need to burn battery on
|
||||
// health probes the user can't see the result of.
|
||||
_stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
/// Banner that surfaces when the server reports our client is too old.
|
||||
/// Non-blocking: the rest of the app keeps working (offline-mode-style
|
||||
/// — locally cached metadata + audio still play). Tap "Check now" to
|
||||
/// force a re-check after installing a new build.
|
||||
class VersionTooOldBanner extends ConsumerWidget {
|
||||
const VersionTooOldBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncResult = ref.watch(versionCheckProvider);
|
||||
final result = asyncResult.asData?.value;
|
||||
if (result != VersionResult.tooOld) return const SizedBox.shrink();
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Container(
|
||||
color: fs.error.withValues(alpha: 0.15),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, color: fs.error, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"This app is older than the server requires. "
|
||||
"You can keep using cached content; install an update when ready.",
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(versionCheckProvider.notifier).recheck(),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: fs.parchment,
|
||||
minimumSize: const Size(0, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text('Check now'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import 'installer.dart';
|
||||
@@ -50,20 +49,52 @@ class ClientUpdateController extends AsyncNotifier<UpdateInfo?> {
|
||||
}
|
||||
|
||||
/// True when `serverVersion` is strictly newer than `installedVersion`.
|
||||
/// Both strings are normalized (leading 'v' stripped) and parsed as
|
||||
/// semver. On parse failure, falls back to string inequality (treats
|
||||
/// any difference as "newer" — operator can dismiss if wrong).
|
||||
///
|
||||
/// Comparison strategy: split both strings on `.`, parse each component
|
||||
/// as an int (non-numeric = 0), pad the shorter list with zeros, then
|
||||
/// compare component-wise. This handles our date-style versions
|
||||
/// (2026.05.10.1) which exceed the 3-part semver shape that
|
||||
/// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as
|
||||
/// equal to "2026.05.10.0" rather than "different = newer".
|
||||
///
|
||||
/// Falls back to pub_semver as a secondary attempt when the components
|
||||
/// look semver-like (handles pre-release suffixes, build metadata, etc).
|
||||
///
|
||||
/// Exposed for testing; the polling logic in ClientUpdateController
|
||||
/// is the only production caller.
|
||||
bool isVersionNewer(String serverVersion, String installedVersion) {
|
||||
final svr = serverVersion.replaceFirst(RegExp(r'^v'), '');
|
||||
final ins = installedVersion.replaceFirst(RegExp(r'^v'), '');
|
||||
try {
|
||||
return Version.parse(svr) > Version.parse(ins);
|
||||
} catch (_) {
|
||||
return svr != ins;
|
||||
List<int> parts(String v) => v
|
||||
.replaceFirst(RegExp(r'^v'), '')
|
||||
.split('.')
|
||||
.map((p) => int.tryParse(p) ?? 0)
|
||||
.toList();
|
||||
|
||||
final svr = parts(serverVersion);
|
||||
final ins = parts(installedVersion);
|
||||
|
||||
// Safety net for non-numeric versions (e.g. branch-name builds like
|
||||
// "main" vs "dev"): if neither side parsed any non-zero component,
|
||||
// fall back to string inequality so an operator on a dev build
|
||||
// still gets the banner instead of silently matching everything.
|
||||
final svrAllZero = svr.every((c) => c == 0);
|
||||
final insAllZero = ins.every((c) => c == 0);
|
||||
if (svrAllZero && insAllZero) {
|
||||
return serverVersion.replaceFirst(RegExp(r'^v'), '') !=
|
||||
installedVersion.replaceFirst(RegExp(r'^v'), '');
|
||||
}
|
||||
|
||||
final n = svr.length > ins.length ? svr.length : ins.length;
|
||||
while (svr.length < n) {
|
||||
svr.add(0);
|
||||
}
|
||||
while (ins.length < n) {
|
||||
ins.add(0);
|
||||
}
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (svr[i] > ins[i]) return true;
|
||||
if (svr[i] < ins[i]) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final clientUpdateProvider =
|
||||
|
||||
@@ -62,11 +62,12 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.system_update, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.system_update, color: fs.parchment, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -76,11 +77,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
_stage == _Stage.error
|
||||
? 'Update failed'
|
||||
: 'Update Minstrel · ${info.version} available',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
style: TextStyle(color: fs.parchment, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_stage == _Stage.downloading) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
LinearProgressIndicator(
|
||||
value: _progress > 0 ? _progress : null,
|
||||
minHeight: 2,
|
||||
@@ -89,11 +90,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
),
|
||||
],
|
||||
if (_stage == _Stage.error && _error != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
@@ -105,20 +106,35 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
onPressed: () => _onInstall(info),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: fs.accent,
|
||||
minimumSize: const Size(64, 36),
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('Install'),
|
||||
child: const Text('Install', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
if (_stage == _Stage.error)
|
||||
TextButton(
|
||||
onPressed: () => _onInstall(info),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.accent),
|
||||
child: const Text('Retry'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: fs.accent,
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('Retry', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 16,
|
||||
icon: Icon(Icons.close, color: fs.ash),
|
||||
onPressed: () => _onDismiss(info),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
icon: Icon(Icons.close, size: 18, color: fs.ash),
|
||||
onPressed: () => _onDismiss(info),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+258
-2
@@ -73,6 +73,78 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +153,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -113,6 +209,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -145,6 +257,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -161,6 +289,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_flutter
|
||||
sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -199,7 +351,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
@@ -283,6 +435,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_timezone
|
||||
sha256: "13b2109ad75651faced4831bf262e32559e44aa549426eab8a597610d385d934"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -320,6 +480,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +552,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -496,6 +672,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -512,6 +696,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -536,6 +728,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
palette_generator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: palette_generator
|
||||
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+7"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -553,7 +753,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -640,6 +840,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -701,6 +917,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -765,6 +989,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.43.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -789,6 +1037,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: minstrel
|
||||
description: Minstrel mobile client
|
||||
publish_to: 'none'
|
||||
version: 0.1.0+1
|
||||
version: 2026.5.13+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
@@ -28,6 +28,21 @@ dependencies:
|
||||
drift_flutter: ^0.2.0
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
connectivity_plus: ^6.0.5
|
||||
flutter_timezone: ^4.1.1
|
||||
palette_generator: ^0.3.3
|
||||
# Disk-persistent image cache. Image.network only caches in memory, so
|
||||
# cover art repainted after a scroll-off or app restart re-fetches from
|
||||
# the server. cached_network_image stores the bytes under
|
||||
# path_provider's temp dir keyed by URL, surviving both. Used directly
|
||||
# by ServerImage (auth-aware path) and as CachedNetworkImageProvider
|
||||
# for the mini bar (which composes its own Image widget).
|
||||
cached_network_image: ^3.4.1
|
||||
# flutter_cache_manager is the disk-cache layer cached_network_image
|
||||
# sits on top of. SyncController uses DefaultCacheManager directly to
|
||||
# pre-warm covers during metadata sync, so a cold-start home grid
|
||||
# paints from disk on the very first scroll rather than firing a
|
||||
# network round-trip per tile.
|
||||
flutter_cache_manager: ^3.4.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -5,28 +5,25 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:minstrel/api/endpoints/playlists.dart';
|
||||
import 'package:minstrel/library/home_screen.dart';
|
||||
import 'package:minstrel/library/library_providers.dart';
|
||||
import 'package:minstrel/models/album.dart';
|
||||
import 'package:minstrel/models/artist.dart';
|
||||
import 'package:minstrel/models/home_data.dart';
|
||||
import 'package:minstrel/models/home_index.dart';
|
||||
import 'package:minstrel/models/playlist.dart';
|
||||
import 'package:minstrel/models/system_playlists_status.dart';
|
||||
import 'package:minstrel/playlists/playlists_provider.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
const _emptyHome = HomeData(
|
||||
recentlyAddedAlbums: [],
|
||||
rediscoverAlbums: [],
|
||||
rediscoverArtists: [],
|
||||
mostPlayedTracks: [],
|
||||
lastPlayedArtists: [],
|
||||
);
|
||||
// All tests pump an empty HomeIndex unless they care about populated
|
||||
// section IDs — per-tile hydration is intentionally not exercised here
|
||||
// (that requires drift, and the @Tags(['drift']) tier covers it).
|
||||
// What this suite verifies is the screen's section-level shape:
|
||||
// placeholders / empty-state copy / section presence given the index.
|
||||
const _emptyIndex = HomeIndex.empty;
|
||||
|
||||
void main() {
|
||||
testWidgets('renders 4 placeholder cards in Playlists row when no system playlists exist',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
||||
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||
playlistsListProvider('all').overrideWith(
|
||||
(ref) => Stream.value(PlaylistsList.empty()),
|
||||
),
|
||||
@@ -37,16 +34,17 @@ void main() {
|
||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
// For-You placeholder + 3 Songs-like placeholders.
|
||||
// For-You placeholder + Discover placeholder + 3 Songs-like placeholders.
|
||||
expect(find.text('For You'), findsOneWidget);
|
||||
expect(find.text('Discover'), findsOneWidget);
|
||||
expect(find.text('Songs like…'), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('renders empty-state copy for each section when home payload is empty',
|
||||
testWidgets('renders empty-state copy for each section when the index is empty',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
||||
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||
playlistsListProvider('all').overrideWith(
|
||||
(ref) => Stream.value(PlaylistsList.empty()),
|
||||
),
|
||||
@@ -87,7 +85,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
homeProvider.overrideWith((ref) async => _emptyHome),
|
||||
homeIndexProvider.overrideWith((ref) => Stream.value(_emptyIndex)),
|
||||
playlistsListProvider('all').overrideWith(
|
||||
(ref) => Stream.value(
|
||||
const PlaylistsList(owned: [forYou], public: [])),
|
||||
@@ -104,34 +102,8 @@ void main() {
|
||||
expect(find.text('for you'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders home rollups when payload is non-empty', (tester) async {
|
||||
const home = HomeData(
|
||||
recentlyAddedAlbums: [
|
||||
AlbumRef(id: 'a', title: 'Geogaddi', artistId: 'x', artistName: 'BoC'),
|
||||
],
|
||||
rediscoverAlbums: [],
|
||||
rediscoverArtists: [
|
||||
ArtistRef(id: 'r', name: 'Aphex Twin'),
|
||||
],
|
||||
mostPlayedTracks: [],
|
||||
lastPlayedArtists: [],
|
||||
);
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
homeProvider.overrideWith((ref) async => home),
|
||||
playlistsListProvider('all').overrideWith(
|
||||
(ref) => Stream.value(PlaylistsList.empty()),
|
||||
),
|
||||
systemPlaylistsStatusProvider.overrideWith(
|
||||
(ref) async => SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(theme: buildThemeData(), home: const HomeScreen()),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Recently added'), findsOneWidget);
|
||||
expect(find.text('Geogaddi'), findsOneWidget);
|
||||
expect(find.text('Rediscover'), findsOneWidget);
|
||||
expect(find.text('Aphex Twin'), findsOneWidget);
|
||||
});
|
||||
// Section-with-populated-IDs coverage lives in the drift-tagged
|
||||
// integration suite (Slice F follow-up) because per-tile providers
|
||||
// require a real (in-memory) drift DB. The flutter-ci runner skips
|
||||
// drift tests pending libsqlite3.
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
@Tags(['drift'])
|
||||
library;
|
||||
|
||||
import 'package:drift/native.dart' show NativeDatabase;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/api/endpoints/quarantine.dart';
|
||||
import 'package:minstrel/models/quarantine_mine.dart';
|
||||
import 'package:minstrel/cache/audio_cache_manager.dart' show appDbProvider;
|
||||
import 'package:minstrel/cache/db.dart';
|
||||
import 'package:minstrel/models/track.dart';
|
||||
import 'package:minstrel/quarantine/quarantine_provider.dart';
|
||||
|
||||
// SKIP NOTE: drift's NativeDatabase needs the system libsqlite3.so.
|
||||
// The flutter-ci runner image doesn't ship it. Same skip as
|
||||
// sync_controller_test.dart — unblock by adding libsqlite3-dev to the
|
||||
// CI image, or switch to sqlite3/wasm. On-device runs cover real
|
||||
// behaviour today.
|
||||
const _skipDrift = 'libsqlite3 missing on flutter-ci runner; on-device covers';
|
||||
|
||||
class _StubQuarantineApi implements QuarantineApi {
|
||||
_StubQuarantineApi({this.shouldThrow = false});
|
||||
bool shouldThrow;
|
||||
@@ -25,13 +37,6 @@ class _StubQuarantineApi implements QuarantineApi {
|
||||
}
|
||||
}
|
||||
|
||||
class _StubController extends MyQuarantineController {
|
||||
_StubController(this._initial);
|
||||
final List<QuarantineMineRow> _initial;
|
||||
@override
|
||||
Future<List<QuarantineMineRow>> build() async => _initial;
|
||||
}
|
||||
|
||||
const _track = TrackRef(
|
||||
id: 't1',
|
||||
title: 'Roygbiv',
|
||||
@@ -44,44 +49,55 @@ const _track = TrackRef(
|
||||
streamUrl: '',
|
||||
);
|
||||
|
||||
const _existing = QuarantineMineRow(
|
||||
trackId: 't1',
|
||||
reason: 'bad_rip',
|
||||
notes: null,
|
||||
createdAt: '2026-05-01T00:00:00Z',
|
||||
trackTitle: 'Roygbiv',
|
||||
trackDurationMs: 137000,
|
||||
albumId: 'a1',
|
||||
albumTitle: 'Geogaddi',
|
||||
artistId: 'ar1',
|
||||
artistName: 'Boards of Canada',
|
||||
);
|
||||
ProviderContainer _container({required AppDb db, required QuarantineApi api}) {
|
||||
return ProviderContainer(overrides: [
|
||||
appDbProvider.overrideWithValue(db),
|
||||
quarantineApiProvider.overrideWith((ref) async => api),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _seed(AppDb db) async {
|
||||
await db.into(db.cachedQuarantineMine).insert(
|
||||
CachedQuarantineMineCompanion.insert(
|
||||
trackId: 't1',
|
||||
reason: 'bad_rip',
|
||||
createdAt: '2026-05-01T00:00:00Z',
|
||||
trackTitle: 'Roygbiv',
|
||||
albumId: 'a1',
|
||||
albumTitle: 'Geogaddi',
|
||||
artistId: 'ar1',
|
||||
artistName: 'Boards of Canada',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('flag prepends synthetic row and calls server', () async {
|
||||
test('flag inserts a drift row and calls the server', skip: _skipDrift,
|
||||
() async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final api = _StubQuarantineApi();
|
||||
final container = ProviderContainer(overrides: [
|
||||
quarantineApiProvider.overrideWith((ref) async => api),
|
||||
myQuarantineProvider.overrideWith(() => _StubController(const [])),
|
||||
]);
|
||||
final container = _container(db: db, api: api);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(myQuarantineProvider.future);
|
||||
await container.read(myQuarantineProvider.notifier).flag(_track, 'bad_rip', '');
|
||||
await container
|
||||
.read(myQuarantineProvider.notifier)
|
||||
.flag(_track, 'bad_rip', '');
|
||||
|
||||
expect(api.flagCalls, 1);
|
||||
final list = container.read(myQuarantineProvider).value!;
|
||||
expect(list, hasLength(1));
|
||||
expect(list.first.trackId, 't1');
|
||||
expect(list.first.reason, 'bad_rip');
|
||||
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.trackId, 't1');
|
||||
expect(rows.first.reason, 'bad_rip');
|
||||
});
|
||||
|
||||
test('flag rolls back on server failure', () async {
|
||||
test('flag rolls back the drift insert on server failure', skip: _skipDrift,
|
||||
() async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final api = _StubQuarantineApi(shouldThrow: true);
|
||||
final container = ProviderContainer(overrides: [
|
||||
quarantineApiProvider.overrideWith((ref) async => api),
|
||||
myQuarantineProvider.overrideWith(() => _StubController(const [])),
|
||||
]);
|
||||
final container = _container(db: db, api: api);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(myQuarantineProvider.future);
|
||||
@@ -91,28 +107,33 @@ void main() {
|
||||
.flag(_track, 'bad_rip', ''),
|
||||
throwsException,
|
||||
);
|
||||
expect(container.read(myQuarantineProvider).value, isEmpty);
|
||||
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('unflag removes the row and calls server', () async {
|
||||
test('unflag deletes the drift row and calls the server', skip: _skipDrift,
|
||||
() async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
await _seed(db);
|
||||
final api = _StubQuarantineApi();
|
||||
final container = ProviderContainer(overrides: [
|
||||
quarantineApiProvider.overrideWith((ref) async => api),
|
||||
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
|
||||
]);
|
||||
final container = _container(db: db, api: api);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(myQuarantineProvider.future);
|
||||
await container.read(myQuarantineProvider.notifier).unflag('t1');
|
||||
|
||||
expect(api.unflagCalls, 1);
|
||||
expect(container.read(myQuarantineProvider).value, isEmpty);
|
||||
final rows = await db.select(db.cachedQuarantineMine).get();
|
||||
expect(rows, isEmpty);
|
||||
});
|
||||
|
||||
test('isHidden reflects current state', () async {
|
||||
final container = ProviderContainer(overrides: [
|
||||
myQuarantineProvider.overrideWith(() => _StubController(const [_existing])),
|
||||
]);
|
||||
test('isHidden reflects drift state', skip: _skipDrift, () async {
|
||||
final db = AppDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
await _seed(db);
|
||||
final api = _StubQuarantineApi();
|
||||
final container = _container(db: db, api: api);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(myQuarantineProvider.future);
|
||||
|
||||
@@ -24,17 +24,19 @@ void main() {
|
||||
expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse);
|
||||
});
|
||||
|
||||
test('honors prerelease ordering', () {
|
||||
// 0.1.0 > 0.1.0-rc.1 per semver
|
||||
expect(isVersionNewer('0.1.0', '0.1.0-rc.1'), isTrue);
|
||||
expect(isVersionNewer('0.1.0-rc.1', '0.1.0'), isFalse);
|
||||
test('zero-pads shorter version when comparing', () {
|
||||
// "1.2" treated as "1.2.0" — shorter side gets zero-padded so
|
||||
// comparison is component-wise.
|
||||
expect(isVersionNewer('1.2.1', '1.2'), isTrue);
|
||||
expect(isVersionNewer('1.2', '1.2.1'), isFalse);
|
||||
expect(isVersionNewer('1.2', '1.2.0'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isVersionNewer (date-style versions parse as semver)', () {
|
||||
// pub_semver is permissive with leading zeros — '2026.05.10'
|
||||
// parses as 2026.5.10. So date-style tags get strict ordering,
|
||||
// not the unparseable-fallback path.
|
||||
group('isVersionNewer (date-style versions)', () {
|
||||
// Date tags are 3- or 4-part: 2026.05.10 or 2026.05.10.1. The
|
||||
// numeric comparator parses each component as int (so "05" == 5)
|
||||
// and zero-pads the shorter side.
|
||||
test('newer date → newer', () {
|
||||
expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue);
|
||||
});
|
||||
@@ -44,6 +46,18 @@ void main() {
|
||||
test('equal date → not newer', () {
|
||||
expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse);
|
||||
});
|
||||
test('leading zeros do not affect ordering', () {
|
||||
// "2026.5.11" and "2026.05.11" parse to the same components.
|
||||
// 12 > 5 numerically so month rollover stays correct.
|
||||
expect(isVersionNewer('2026.5.11', '2026.05.11'), isFalse);
|
||||
expect(isVersionNewer('2026.12.1', '2026.5.31'), isTrue);
|
||||
});
|
||||
test('4-part build suffix breaks the tie', () {
|
||||
// 2026.05.10 vs 2026.05.10.1 — first three equal, server has a
|
||||
// 4th component (1), client zero-pads → server is newer.
|
||||
expect(isVersionNewer('2026.05.10.1', '2026.05.10'), isTrue);
|
||||
expect(isVersionNewer('2026.05.10', '2026.05.10.0'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isVersionNewer (truly non-semver fallback)', () {
|
||||
|
||||
@@ -5,10 +5,12 @@ go 1.23.0
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -20,7 +22,9 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
|
||||
@@ -21,6 +21,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -29,6 +31,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -44,6 +48,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -64,13 +70,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
@@ -81,6 +89,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
|
||||
@@ -111,6 +111,8 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
// Broadcast: affects every user who'd flagged this track.
|
||||
h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true)
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
@@ -140,6 +142,9 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
return
|
||||
}
|
||||
// Broadcast: the track row is gone; every client's library/likes/queue
|
||||
// invalidates so stale references stop showing.
|
||||
h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true)
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
@@ -179,6 +184,8 @@ func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *htt
|
||||
}
|
||||
return
|
||||
}
|
||||
// Broadcast: same rationale as delete-file — track row gone everywhere.
|
||||
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
|
||||
@@ -125,6 +125,7 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -164,5 +165,6 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||
}
|
||||
|
||||
+9
-1
@@ -14,6 +14,7 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
@@ -27,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -45,6 +46,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -62,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
authed.Put("/me/password", h.handleChangePassword)
|
||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||
|
||||
@@ -78,7 +82,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/radio", h.handleRadio)
|
||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||
authed.Get("/home", h.handleGetHome)
|
||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||
authed.Post("/events", h.handleEvents)
|
||||
authed.Get("/events/stream", h.handleEventsStream)
|
||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||||
@@ -191,4 +197,6 @@ type handlers struct {
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
}
|
||||
|
||||
@@ -202,6 +202,17 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
map[string]any{"token": usedInviteToken})
|
||||
}
|
||||
|
||||
// Register the new user with the playlist scheduler so their daily
|
||||
// builds are scheduled without waiting for the hourly reconciliation
|
||||
// pass. Failure here is logged but doesn't fail registration — the
|
||||
// next hourly pass will pick them up.
|
||||
if h.playlistScheduler != nil {
|
||||
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
|
||||
h.logger.Warn("api: scheduler refresh after registration",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, LoginResponse{
|
||||
Token: sessionToken,
|
||||
User: UserView{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Small helpers for posting live events to the SSE bus (#392). Producers
|
||||
// call these from their handler success paths so the wire shape stays in
|
||||
// one place. Each helper is a no-op when h.eventbus is nil (older callers
|
||||
// or tests that don't construct a bus); production always has one.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// publishLikeEvent broadcasts a track/album/artist like or unlike. The
|
||||
// event is scoped to the user (so only their own clients invalidate); the
|
||||
// payload includes the entity_type and entity_id so the dispatcher can
|
||||
// invalidate the right query keys.
|
||||
func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType string, liked bool) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
kind := "track.liked"
|
||||
if !liked {
|
||||
kind = "track.unliked"
|
||||
}
|
||||
if entityType == "album" {
|
||||
if liked {
|
||||
kind = "album.liked"
|
||||
} else {
|
||||
kind = "album.unliked"
|
||||
}
|
||||
}
|
||||
if entityType == "artist" {
|
||||
if liked {
|
||||
kind = "artist.liked"
|
||||
} else {
|
||||
kind = "artist.unliked"
|
||||
}
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: kind,
|
||||
UserID: uuidToString(userID),
|
||||
Data: map[string]any{
|
||||
"entity_type": entityType,
|
||||
"entity_id": uuidToString(entityID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// publishQuarantineEvent broadcasts a quarantine action. User-side
|
||||
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
|
||||
// delete-via-lidarr are broadcast (empty UserID) because a single admin
|
||||
// action can affect every user who'd flagged that track — every client
|
||||
// invalidates and the non-affected ones no-op when the query result
|
||||
// matches their cache.
|
||||
func (h *handlers) publishQuarantineEvent(kind string, userID, trackID pgtype.UUID, broadcast bool) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
scope := ""
|
||||
if !broadcast {
|
||||
scope = uuidToString(userID)
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: kind,
|
||||
UserID: scope,
|
||||
Data: map[string]any{
|
||||
"track_id": uuidToString(trackID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// publishPlaylistEvent broadcasts a playlist mutation to the owner so
|
||||
// their other clients invalidate the affected playlist (and playlist list)
|
||||
// providers. Public-playlist subscribers are out of scope here — they'd
|
||||
// need a separate "playlists.public_updated" broadcast, deferred until
|
||||
// the multi-user case is exercised.
|
||||
func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.UUID) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: kind,
|
||||
UserID: uuidToString(ownerID),
|
||||
Data: map[string]any{
|
||||
"playlist_id": uuidToString(playlistID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||
// the request's original requester so their /requests page reflects the
|
||||
// new state without manual refresh. Admin actors (approve / reject) still
|
||||
// route to the requester's user_id, not the admin's — the original user
|
||||
// is who needs to see the update.
|
||||
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: "request.status_changed",
|
||||
UserID: uuidToString(row.UserID),
|
||||
Data: map[string]any{
|
||||
"request_id": uuidToString(row.ID),
|
||||
"status": string(row.Status),
|
||||
"kind": string(row.Kind),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Live event stream — Server-Sent Events at GET /api/events/stream.
|
||||
//
|
||||
// Clients (today: Flutter) subscribe to be notified of state changes
|
||||
// elsewhere in the system (likes, request status flips, quarantine
|
||||
// resolutions, scan progress, playlist mutations) so static screens
|
||||
// can invalidate cached data without explicit user action.
|
||||
//
|
||||
// Wire-format: standard SSE. Each event has the shape
|
||||
//
|
||||
// event: <kind>
|
||||
// data: <json payload>
|
||||
//
|
||||
// where <kind> follows "domain.action" naming and <json payload>
|
||||
// matches `eventbus.Event`. A heartbeat comment line is emitted every
|
||||
// 15 seconds to keep idle connections alive through proxies / NATs.
|
||||
//
|
||||
// Scoping: events with `user_id` matching the connected user OR with
|
||||
// empty `user_id` (broadcast) are forwarded. Other users' events are
|
||||
// dropped silently before reaching the wire.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
)
|
||||
|
||||
// userUUIDString renders a pgtype.UUID in canonical 8-4-4-4-12 form so
|
||||
// the SSE handler can compare against eventbus.Event.UserID strings.
|
||||
// Other packages keep their own copies of this same pattern; consolidating
|
||||
// is out of scope for this slice.
|
||||
func userUUIDString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
|
||||
}
|
||||
|
||||
const (
|
||||
// sseSubscriberBuffer bounds per-connection event queue depth. Events
|
||||
// beyond this are dropped (logged at debug); the client resyncs via
|
||||
// normal /api/* fetches when reconnecting or focusing.
|
||||
sseSubscriberBuffer = 64
|
||||
// sseHeartbeatInterval keeps proxy connections alive. SSE spec allows
|
||||
// a comment line (starts with ":") as a no-op heartbeat.
|
||||
sseHeartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
func (h *handlers) handleEventsStream(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Standard SSE headers. X-Accel-Buffering disables nginx's response
|
||||
// buffering when Minstrel sits behind it; harmless on direct
|
||||
// connections.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Initial comment so the client sees the connection open immediately
|
||||
// even if no real event lands for a while.
|
||||
if _, err := fmt.Fprintf(w, ": connected\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
ch, unsub := h.eventbus.Subscribe(sseSubscriberBuffer)
|
||||
defer unsub()
|
||||
|
||||
heartbeat := time.NewTicker(sseHeartbeatInterval)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
userIDStr := userUUIDString(user.ID)
|
||||
ctx := r.Context()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprintf(w, ": heartbeat\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
case e, ok := <-ch:
|
||||
if !ok {
|
||||
// Bus closed our subscription (rare; only on full shutdown).
|
||||
return
|
||||
}
|
||||
// Scope filter: forward only events scoped to this user or
|
||||
// broadcast events (empty UserID).
|
||||
if e.UserID != "" && e.UserID != userIDStr {
|
||||
continue
|
||||
}
|
||||
if err := writeSSEEvent(w, e); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeSSEEvent serializes one bus event in SSE wire format. JSON
|
||||
// marshal errors collapse the event to an "error" kind so the client
|
||||
// at least sees something rather than silent drops.
|
||||
func writeSSEEvent(w http.ResponseWriter, e eventbus.Event) error {
|
||||
payload, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
_, werr := fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"marshal failed\"}\n\n")
|
||||
return werr
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Kind, payload)
|
||||
return err
|
||||
}
|
||||
@@ -49,6 +49,54 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleGetHomeIndex implements GET /api/home/index. Returns the same
|
||||
// five sections /api/home computes, but as flat slices of entity IDs
|
||||
// rather than denormalized objects. Drives the Flutter per-item
|
||||
// rendering path — the client hydrates each tile against the
|
||||
// per-entity endpoints, so this response is tiny (a few KB at most)
|
||||
// and the cold-visit round-trip is correspondingly short.
|
||||
//
|
||||
// Shares the recommendation.HomeData computation with /api/home; the
|
||||
// DB cost is identical. A future optimisation could add ID-only
|
||||
// query variants, but the JSON savings already shrink the wire
|
||||
// payload by roughly an order of magnitude on populated libraries.
|
||||
func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := recommendation.HomeData(r.Context(), h.pool, user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: home index data", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("failed to load home", err))
|
||||
return
|
||||
}
|
||||
|
||||
out := HomeIndexPayload{
|
||||
RecentlyAddedAlbums: make([]string, 0, len(data.RecentlyAddedAlbums)),
|
||||
RediscoverAlbums: make([]string, 0, len(data.RediscoverAlbums)),
|
||||
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
|
||||
}
|
||||
for _, row := range data.RediscoverAlbums {
|
||||
out.RediscoverAlbums = append(out.RediscoverAlbums, uuidToString(row.Album.ID))
|
||||
}
|
||||
for _, row := range data.RediscoverArtists {
|
||||
out.RediscoverArtists = append(out.RediscoverArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
for _, row := range data.MostPlayedTracks {
|
||||
out.MostPlayedTracks = append(out.MostPlayedTracks, uuidToString(row.Track.ID))
|
||||
}
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Compile-time guard that dbq has the row types we need; if the SQL is
|
||||
// regenerated with a different name, this fails fast at build time.
|
||||
var _ = dbq.ListMostPlayedTracksForUserRow{}.Track
|
||||
|
||||
@@ -77,3 +77,67 @@ func TestHome_RecentlyAddedSurfacesAlbums(t *testing.T) {
|
||||
t.Errorf("artist_name = %q, want ArtistOne", got.RecentlyAddedAlbums[0].ArtistName)
|
||||
}
|
||||
}
|
||||
|
||||
func newHomeIndexRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/home/index", h.handleGetHomeIndex)
|
||||
return r
|
||||
}
|
||||
|
||||
func doGetHomeIndex(h *handlers, user dbq.User) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/home/index", nil)
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
newHomeIndexRouter(h).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestHomeIndex_EmptyForNewUser(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
|
||||
w := doGetHomeIndex(h, user)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got HomeIndexPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Same non-nil slice contract as /api/home so client parsers can
|
||||
// trust `length == 0` instead of branching on null.
|
||||
if got.RecentlyAddedAlbums == nil || got.RediscoverAlbums == nil ||
|
||||
got.RediscoverArtists == nil || got.MostPlayedTracks == nil ||
|
||||
got.LastPlayedArtists == nil {
|
||||
t.Errorf("payload has nil slice: %+v", got)
|
||||
}
|
||||
if len(got.RecentlyAddedAlbums) != 0 {
|
||||
t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeIndex_RecentlyAddedReturnsIDs(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "pw", false)
|
||||
q := dbq.New(pool)
|
||||
artist, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: "ArtistOne", SortName: "ArtistOne",
|
||||
})
|
||||
album, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: "Newest", SortTitle: "Newest", ArtistID: artist.ID,
|
||||
})
|
||||
|
||||
w := doGetHomeIndex(h, user)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got HomeIndexPayload
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if len(got.RecentlyAddedAlbums) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got.RecentlyAddedAlbums))
|
||||
}
|
||||
want := uuidToString(album.ID)
|
||||
if got.RecentlyAddedAlbums[0] != want {
|
||||
t.Errorf("id = %q, want %q", got.RecentlyAddedAlbums[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-20
@@ -40,19 +40,28 @@ func (h *handlers) handleGetTrack(w http.ResponseWriter, r *http.Request) {
|
||||
// handleGetAlbum implements GET /api/albums/{id}. Returns the album plus its
|
||||
// tracks (ordered by disc/track number via the underlying query) with
|
||||
// duration summed from the track list — keeps one source of truth.
|
||||
//
|
||||
// Two DB round trips: the album+artist join and the per-user tracks
|
||||
// list. Down from three (separate album, artist, tracks) before
|
||||
// GetAlbumWithArtist landed.
|
||||
func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
|
||||
if apiErr != nil {
|
||||
writeErr(w, apiErr)
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
artist, err := q.GetArtistByID(r.Context(), album.ArtistID)
|
||||
row, err := q.GetAlbumWithArtist(r.Context(), id)
|
||||
if err != nil {
|
||||
h.logger.Error("api: get album artist failed", "err", err)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("album"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get album+artist failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
album := row.Album
|
||||
artistName := row.ArtistName
|
||||
var userID pgtype.UUID
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
userID = user.ID
|
||||
@@ -68,20 +77,24 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
refs := make([]TrackRef, 0, len(tracks))
|
||||
durSec := 0
|
||||
for _, t := range tracks {
|
||||
ref := trackRefFrom(t, album.Title, artist.Name)
|
||||
ref := trackRefFrom(t, album.Title, artistName)
|
||||
refs = append(refs, ref)
|
||||
durSec += ref.DurationSec
|
||||
}
|
||||
detail := AlbumDetail{
|
||||
AlbumRef: albumRefFrom(album, artist.Name, len(tracks), durSec),
|
||||
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||
Tracks: refs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// handleGetArtist implements GET /api/artists/{id}. Returns artist + albums;
|
||||
// each album carries its own track_count (one count query per album, same
|
||||
// pattern as subsonic). At M1 sizes (albums-per-artist << 100) this is fine.
|
||||
// each album carries its own track_count.
|
||||
//
|
||||
// Down from 1 + 1 + N queries (artist, albums, per-album CountTracksByAlbum)
|
||||
// to 1 + 1 (artist, albums-with-track-count via correlated subquery).
|
||||
// On a 30-album artist that's ~32 round trips collapsed to 2 — the
|
||||
// difference between "feels slow" and "feels instant" on detail nav.
|
||||
func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
artist, apiErr := resolveByID(r, "id", q.GetArtistByID, "artist")
|
||||
@@ -89,25 +102,19 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, apiErr)
|
||||
return
|
||||
}
|
||||
albums, err := q.ListAlbumsByArtist(r.Context(), artist.ID)
|
||||
rows, err := q.ListAlbumsByArtistWithTrackCount(r.Context(), artist.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("api: list albums by artist failed", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
refs := make([]AlbumRef, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
count, cerr := q.CountTracksByAlbum(r.Context(), a.ID)
|
||||
if cerr != nil {
|
||||
h.logger.Error("api: count tracks failed", "err", cerr)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", cerr))
|
||||
return
|
||||
}
|
||||
refs := make([]AlbumRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||
refs = append(refs, albumRefFrom(a, artist.Name, int(count), 0))
|
||||
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
||||
}
|
||||
detail := ArtistDetail{
|
||||
ArtistRef: artistRefFrom(artist, len(albums)),
|
||||
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||
Albums: refs,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
@@ -443,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -60,6 +60,7 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
||||
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
|
||||
h.publishLikeEvent(user.ID, id, "track", true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
||||
// Don't fail the response — soft-delete is best-effort.
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
|
||||
h.publishLikeEvent(user.ID, id, "track", false)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpUpsert)
|
||||
h.publishLikeEvent(user.ID, id, "album", true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -129,6 +132,7 @@ func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeAlbum, user.ID, id, syncpkg.OpDelete)
|
||||
h.publishLikeEvent(user.ID, id, "album", false)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -157,6 +161,7 @@ func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpUpsert)
|
||||
h.publishLikeEvent(user.ID, id, "artist", true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -175,6 +180,7 @@ func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.logLikeChange(r, syncpkg.EntityLikeArtist, user.ID, id, syncpkg.OpDelete)
|
||||
h.publishLikeEvent(user.ID, id, "artist", false)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// PUT /api/me/timezone — accept the client's IANA timezone and store
|
||||
// it on the user row. The system-playlist scheduler (#392 Half B)
|
||||
// reads this column to fire each user's daily 03:00 build in their
|
||||
// local time.
|
||||
//
|
||||
// Scheduler.Refresh(ctx, userID) is invoked in a later commit (Task 3)
|
||||
// so a timezone change takes effect synchronously rather than waiting
|
||||
// for the hourly reconciliation pass.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// putTimezoneBody is the JSON body for PUT /api/me/timezone.
|
||||
type putTimezoneBody struct {
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, apierror.Unauthorized("unauthorized", "authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
var body putTimezoneBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
|
||||
return
|
||||
}
|
||||
if body.Timezone == "" {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "timezone is required"))
|
||||
return
|
||||
}
|
||||
if _, err := time.LoadLocation(body.Timezone); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_timezone",
|
||||
"timezone must be a valid IANA name (e.g. America/New_York)"))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.UpdateUserTimezone(r.Context(), dbq.UpdateUserTimezoneParams{
|
||||
ID: user.ID,
|
||||
Timezone: body.Timezone,
|
||||
}); err != nil {
|
||||
h.logger.Error("api: update timezone", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Reschedule the user's daily build at their new timezone. Failure
|
||||
// here doesn't undo the DB write — the hourly reconciliation pass
|
||||
// would pick up the new tz within an hour at worst.
|
||||
if h.playlistScheduler != nil {
|
||||
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
|
||||
h.logger.Warn("api: scheduler refresh after timezone update",
|
||||
"user_id", uuidToString(user.ID), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func newMeTimezoneRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Put("/api/me/timezone", h.handlePutTimezone)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPutTimezone_ValidIANA(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "tz1", "irrelevant1", false)
|
||||
|
||||
body := bytes.NewBufferString(`{"timezone":"America/New_York"}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMeTimezoneRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify the DB row was updated.
|
||||
got, err := dbq.New(pool).GetUserByID(context.Background(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
if got.Timezone != "America/New_York" {
|
||||
t.Errorf("timezone = %q, want %q", got.Timezone, "America/New_York")
|
||||
}
|
||||
if !got.TimezoneUpdatedAt.Valid {
|
||||
t.Errorf("expected timezone_updated_at to be set after PUT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutTimezone_InvalidIANA(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "tz2", "irrelevant1", false)
|
||||
|
||||
body := bytes.NewBufferString(`{"timezone":"Not/A/Real/Zone"}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMeTimezoneRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var errResp struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
|
||||
t.Fatalf("decode err response: %v", err)
|
||||
}
|
||||
if errResp.Code != "invalid_timezone" {
|
||||
t.Errorf("error code = %q, want invalid_timezone", errResp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutTimezone_EmptyTimezone(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "tz3", "irrelevant1", false)
|
||||
|
||||
body := bytes.NewBufferString(`{"timezone":""}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMeTimezoneRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", imageContentType(path))
|
||||
// Cover bytes change rarely (cover-source enrichment, manual rescan,
|
||||
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
|
||||
// clients skip the conditional GET for the bulk of a session, but
|
||||
// stale art clears within 24h after a re-scan. ServeContent below
|
||||
// still emits Last-Modified for the conditional path when needed.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -140,5 +146,11 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
// Track bytes are immutable for a given track id (the scanner
|
||||
// indexes by file_path; re-encoded files take new ids). One-year
|
||||
// max-age + immutable lets the client cache (LockCachingAudioSource
|
||||
// on the Flutter side, browser cache on web) skip even the
|
||||
// conditional GET on repeat plays.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request)
|
||||
h.writePlaylistErr(w, err, "create")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.created", caller.ID, row.ID)
|
||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||
}
|
||||
|
||||
@@ -240,6 +241,7 @@ func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request)
|
||||
h.writePlaylistErr(w, err, "update")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.updated", caller.ID, playlistID)
|
||||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||
}
|
||||
|
||||
@@ -258,6 +260,7 @@ func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request)
|
||||
h.writePlaylistErr(w, err, "delete")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.deleted", caller.ID, playlistID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -294,6 +297,7 @@ func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
|
||||
h.writePlaylistErr(w, err, "append tracks")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-append")
|
||||
@@ -323,6 +327,7 @@ func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Requ
|
||||
h.writePlaylistErr(w, err, "remove track")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-remove")
|
||||
@@ -355,6 +360,7 @@ func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request)
|
||||
h.writePlaylistErr(w, err, "reorder")
|
||||
return
|
||||
}
|
||||
h.publishPlaylistEvent("playlist.tracks_changed", caller.ID, playlistID)
|
||||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||
if err != nil {
|
||||
h.writePlaylistErr(w, err, "get-after-reorder")
|
||||
@@ -386,6 +392,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||||
// Playlist collages recompute when the playlist's tracks change
|
||||
// (system playlists re-rendered on rebuild, user playlists when
|
||||
// modified). 5 minutes is short enough for normal edits to feel
|
||||
// fresh, long enough to skip repeat fetches during a session.
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Broadcast: the flagging user's other clients invalidate their
|
||||
// Hidden tab; admins' clients invalidate their quarantine queue.
|
||||
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
|
||||
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -89,6 +92,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, apierror.InternalMsg("unflag failed", err))
|
||||
return
|
||||
}
|
||||
// Scoped to user: only their other clients need to invalidate.
|
||||
h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusCreated, requestViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -254,5 +255,6 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.publishRequestStatusChanged(row)
|
||||
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
||||
}
|
||||
|
||||
@@ -118,6 +118,24 @@ type HomePayload struct {
|
||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
||||
}
|
||||
|
||||
// HomeIndexPayload is the response body of GET /api/home/index — the
|
||||
// per-item rendering variant of /api/home. Same sections, same caps,
|
||||
// but each section is a flat slice of entity IDs (UUID strings)
|
||||
// instead of denormalized objects. Slices are non-nil at JSON encode
|
||||
// time so empty sections render as `[]`.
|
||||
//
|
||||
// Client fetches this, then hydrates each tile against the per-entity
|
||||
// endpoints (/api/albums/{id}, /api/artists/{id}, /api/tracks/{id})
|
||||
// for genuine progressive rendering. The section name implies the
|
||||
// entity type so no per-entry type tag is needed.
|
||||
type HomeIndexPayload struct {
|
||||
RecentlyAddedAlbums []string `json:"recently_added_albums"`
|
||||
RediscoverAlbums []string `json:"rediscover_albums"`
|
||||
RediscoverArtists []string `json:"rediscover_artists"`
|
||||
MostPlayedTracks []string `json:"most_played_tracks"`
|
||||
LastPlayedArtists []string `json:"last_played_artists"`
|
||||
}
|
||||
|
||||
// HistoryEvent is one play in a user's listening history. Used by
|
||||
// /api/me/history; one event per row from play_events.
|
||||
type HistoryEvent struct {
|
||||
|
||||
@@ -168,6 +168,41 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.id = $1
|
||||
`
|
||||
|
||||
type GetAlbumWithArtistRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Combined fetch for /api/albums/{id}: returns the album row + the
|
||||
// joined artist name in a single round trip. Replaces a sequential
|
||||
// GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
|
||||
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
|
||||
var i GetAlbumWithArtistRow
|
||||
err := row.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
|
||||
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
@@ -389,6 +424,56 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||
AS track_count
|
||||
FROM albums
|
||||
WHERE albums.artist_id = $1
|
||||
ORDER BY release_date NULLS LAST, sort_title
|
||||
`
|
||||
|
||||
type ListAlbumsByArtistWithTrackCountRow struct {
|
||||
Album Album
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
// Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||
// per album). Returns each album joined with its track count via a
|
||||
// correlated subquery — single round trip regardless of album count.
|
||||
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListAlbumsByArtistWithTrackCountRow
|
||||
for rows.Next() {
|
||||
var i ListAlbumsByArtistWithTrackCountRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.TrackCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||
FROM albums
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksForDiscover :many
|
||||
SELECT DISTINCT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id != $1
|
||||
@@ -30,6 +30,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.album_id, t.artist_id
|
||||
ORDER BY md5(t.id::text || $2::text)
|
||||
LIMIT 60
|
||||
`
|
||||
@@ -50,6 +51,11 @@ type ListCrossUserLikedTracksForDiscoverRow struct {
|
||||
// rows (the caller's slot redistribution rolls the deficit into the
|
||||
// other two buckets).
|
||||
// $1 = user_id, $2 = date string for md5 ordering.
|
||||
//
|
||||
// GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
|
||||
// not appear in the select list — Postgres rejects DISTINCT + ORDER BY
|
||||
// by-expression at plan time (SQLSTATE 42P10), which previously caused
|
||||
// the entire Discover build to fail silently.
|
||||
func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) {
|
||||
rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,8 +7,25 @@ package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteOldLibraryChanges = `-- name: DeleteOldLibraryChanges :execrows
|
||||
DELETE FROM library_changes WHERE changed_at < $1
|
||||
`
|
||||
|
||||
// Removes library_changes rows older than the given cutoff. Run from
|
||||
// the sync compactor goroutine on a daily tick. Returns the number of
|
||||
// rows deleted so the worker can log meaningful progress.
|
||||
func (q *Queries) DeleteOldLibraryChanges(ctx context.Context, changedAt pgtype.Timestamptz) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteOldLibraryChanges, changedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getLibraryChangesSince = `-- name: GetLibraryChangesSince :many
|
||||
SELECT id, entity_type, entity_id, op, changed_at
|
||||
FROM library_changes
|
||||
|
||||
@@ -522,6 +522,8 @@ type User struct {
|
||||
DisplayName *string
|
||||
AutoApproveRequests bool
|
||||
Email *string
|
||||
Timezone string
|
||||
TimezoneUpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
|
||||
@@ -12,17 +12,19 @@ import (
|
||||
)
|
||||
|
||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
max_started.started_at::timestamptz AS last_played_at
|
||||
FROM artists a
|
||||
JOIN LATERAL (
|
||||
SELECT max(pe.started_at) AS started_at
|
||||
WITH user_plays AS (
|
||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
||||
) max_started ON max_started.started_at IS NOT NULL
|
||||
WHERE pe.user_id = $1
|
||||
GROUP BY t.artist_id
|
||||
)
|
||||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
up.last_started::timestamptz AS last_played_at
|
||||
FROM user_plays up
|
||||
JOIN artists a ON a.id = up.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||
@@ -32,7 +34,7 @@ LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = a.id
|
||||
) cnt ON true
|
||||
ORDER BY max_started.started_at DESC, a.id
|
||||
ORDER BY up.last_started DESC, a.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
@@ -52,6 +54,15 @@ type ListLastPlayedArtistsForUserRow struct {
|
||||
// a derived cover_album_id via a representative-album lateral join (most
|
||||
// recent album that has cover_art_path set). album_count joined for the
|
||||
// ArtistRef wire shape.
|
||||
//
|
||||
// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||
// subquery per artist — O(total_artists) plan even for users with a
|
||||
// handful of plays. New shape aggregates the user's plays by artist
|
||||
// via the play_events → tracks join up front (uses
|
||||
// play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||
// the artist row and lateral cover/count subqueries only for the
|
||||
// artists that actually appear. Distinct-artists set is small for a
|
||||
// typical user, so cost is bounded by play history not library size.
|
||||
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
@@ -87,24 +98,24 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
JOIN play_events pe ON pe.track_id = t.id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title, artists.name
|
||||
ORDER BY count(*) DESC, t.id
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
@@ -122,6 +133,11 @@ type ListMostPlayedTracksForUserRow struct {
|
||||
// M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||
// excludes skips so a user spamming next doesn't fabricate a top track.
|
||||
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||
//
|
||||
// Aggregate play_events first (uses play_events_user_track_idx) and
|
||||
// join through tracks/albums/artists only for the survivors. Earlier
|
||||
// shape did the join across every play_event row before grouping —
|
||||
// O(plays) instead of O(distinct tracks).
|
||||
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -308,7 +308,7 @@ SELECT p.artist_id,
|
||||
FROM plays p
|
||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
||||
ORDER BY score DESC, p.artist_id
|
||||
LIMIT 3
|
||||
LIMIT 5
|
||||
`
|
||||
|
||||
type PickSeedArtistsRow struct {
|
||||
@@ -316,7 +316,10 @@ type PickSeedArtistsRow struct {
|
||||
Score int64
|
||||
}
|
||||
|
||||
// Top-3 most-engaged distinct artists in the user's last 7 days.
|
||||
// Top-5 most-engaged distinct artist candidates in the user's last 7
|
||||
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
||||
// daily-deterministically and takes the first 3 so the set of
|
||||
// "Songs like X" mixes rotates day-to-day.
|
||||
// Score = unskipped-play count + 5 if user has liked the artist.
|
||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||
@@ -408,7 +411,7 @@ func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg Pic
|
||||
return id, err
|
||||
}
|
||||
|
||||
const pickTopPlayedTrackForUser = `-- name: PickTopPlayedTrackForUser :one
|
||||
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
@@ -417,16 +420,33 @@ SELECT t.id
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
ORDER BY COUNT(*) DESC, t.id
|
||||
LIMIT 1
|
||||
LIMIT 5
|
||||
`
|
||||
|
||||
// For-You seed selection. Returns the user's most-played non-skipped
|
||||
// track in the last 7 days; tie-break by track_id for determinism.
|
||||
func (q *Queries) PickTopPlayedTrackForUser(ctx context.Context, userID pgtype.UUID) (pgtype.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, pickTopPlayedTrackForUser, userID)
|
||||
var id pgtype.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
// For-You candidate seeds. Returns the user's top-5 most-played
|
||||
// non-skipped tracks in the last 7 days; tie-break by track_id for
|
||||
// determinism. The Go-side picker (pickForYouSeedForDay) chooses one
|
||||
// of the returned rows as today's seed via userIDHash so the
|
||||
// candidate pool rotates day-to-day while staying stable within a
|
||||
// day.
|
||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var id pgtype.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const tryClaimSystemPlaylistRun = `-- name: TryClaimSystemPlaylistRun :one
|
||||
|
||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@@ -87,6 +87,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -94,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type CreateUserAdminParams struct {
|
||||
@@ -130,6 +132,8 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -141,7 +145,7 @@ VALUES (
|
||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||
$4
|
||||
)
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type CreateUserFirstAdminRaceParams struct {
|
||||
@@ -185,6 +189,8 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -231,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
||||
}
|
||||
|
||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE api_token = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
||||
@@ -250,12 +256,14 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE lower(email) = lower($1)
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
|
||||
`
|
||||
|
||||
// Used by forgot-password lookup. Lowercase comparison both sides
|
||||
@@ -277,12 +285,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE id = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||
@@ -301,12 +311,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE username = $1
|
||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||
@@ -325,10 +337,50 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listActiveUsersWithTimezones = `-- name: ListActiveUsersWithTimezones :many
|
||||
SELECT u.id, u.timezone FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = u.id
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
)
|
||||
`
|
||||
|
||||
type ListActiveUsersWithTimezonesRow struct {
|
||||
ID pgtype.UUID
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// Returns (id, timezone) for every user with a play in the last 7
|
||||
// days. The system-playlist scheduler iterates this list at startup
|
||||
// and during its hourly reconciliation to discover newly-active /
|
||||
// no-longer-active users.
|
||||
func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiveUsersWithTimezonesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveUsersWithTimezones)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListActiveUsersWithTimezonesRow
|
||||
for rows.Next() {
|
||||
var i ListActiveUsersWithTimezonesRow
|
||||
if err := rows.Scan(&i.ID, &i.Timezone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
||||
FROM users
|
||||
@@ -374,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
|
||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||
UPDATE users SET api_token = $2 WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type RegenerateApiTokenParams struct {
|
||||
@@ -400,6 +452,8 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -477,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type UpdateUserAdminParams struct {
|
||||
@@ -503,6 +557,8 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -511,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
||||
UPDATE users
|
||||
SET auto_approve_requests = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type UpdateUserAutoApproveParams struct {
|
||||
@@ -536,6 +592,8 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -545,7 +603,7 @@ UPDATE users
|
||||
SET display_name = $2,
|
||||
email = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
||||
`
|
||||
|
||||
type UpdateUserProfileParams struct {
|
||||
@@ -572,6 +630,28 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateUserTimezone = `-- name: UpdateUserTimezone :exec
|
||||
UPDATE users
|
||||
SET timezone = $2,
|
||||
timezone_updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateUserTimezoneParams struct {
|
||||
ID pgtype.UUID
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// Sets the user's IANA timezone and bumps timezone_updated_at. The
|
||||
// handler validates the timezone string via time.LoadLocation before
|
||||
// calling this; the DB does not re-validate.
|
||||
func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error {
|
||||
_, err := q.db.Exec(ctx, updateUserTimezone, arg.ID, arg.Timezone)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN timezone,
|
||||
DROP COLUMN timezone_updated_at;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 0026_users_timezone.up.sql — per-user timezone for the system-playlist
|
||||
-- scheduler (#392 Half B). Default 'UTC' so existing rows + brand-new
|
||||
-- users have a safe value before their first client check-in via
|
||||
-- PUT /api/me/timezone. The scheduler reads this column to decide
|
||||
-- which IANA zone to anchor each user's daily 03:00 build to.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN timezone text NOT NULL DEFAULT 'UTC',
|
||||
ADD COLUMN timezone_updated_at timestamptz;
|
||||
@@ -14,6 +14,26 @@ RETURNING *;
|
||||
-- name: GetAlbumByID :one
|
||||
SELECT * FROM albums WHERE id = $1;
|
||||
|
||||
-- name: GetAlbumWithArtist :one
|
||||
-- Combined fetch for /api/albums/{id}: returns the album row + the
|
||||
-- joined artist name in a single round trip. Replaces a sequential
|
||||
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE albums.id = $1;
|
||||
|
||||
-- name: ListAlbumsByArtistWithTrackCount :many
|
||||
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
|
||||
-- per album). Returns each album joined with its track count via a
|
||||
-- correlated subquery — single round trip regardless of album count.
|
||||
SELECT sqlc.embed(albums),
|
||||
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
|
||||
AS track_count
|
||||
FROM albums
|
||||
WHERE albums.artist_id = $1
|
||||
ORDER BY release_date NULLS LAST, sort_title;
|
||||
|
||||
-- name: GetAlbumByArtistAndTitle :one
|
||||
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
|
||||
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
|
||||
|
||||
@@ -52,7 +52,12 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
-- rows (the caller's slot redistribution rolls the deficit into the
|
||||
-- other two buckets).
|
||||
-- $1 = user_id, $2 = date string for md5 ordering.
|
||||
SELECT DISTINCT t.id, t.album_id, t.artist_id
|
||||
--
|
||||
-- GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
|
||||
-- not appear in the select list — Postgres rejects DISTINCT + ORDER BY
|
||||
-- by-expression at plan time (SQLSTATE 42P10), which previously caused
|
||||
-- the entire Discover build to fail silently.
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id != $1
|
||||
@@ -70,6 +75,7 @@ SELECT DISTINCT t.id, t.album_id, t.artist_id
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.album_id, t.artist_id
|
||||
ORDER BY md5(t.id::text || $2::text)
|
||||
LIMIT 60;
|
||||
|
||||
|
||||
@@ -14,3 +14,9 @@ SELECT COALESCE(MAX(id), 0)::BIGINT FROM library_changes;
|
||||
|
||||
-- name: GetMinLibraryChangeCursor :one
|
||||
SELECT COALESCE(MIN(id), 0)::BIGINT FROM library_changes;
|
||||
|
||||
-- name: DeleteOldLibraryChanges :execrows
|
||||
-- Removes library_changes rows older than the given cutoff. Run from
|
||||
-- the sync compactor goroutine on a daily tick. Returns the number of
|
||||
-- rows deleted so the worker can log meaningful progress.
|
||||
DELETE FROM library_changes WHERE changed_at < $1;
|
||||
|
||||
@@ -206,24 +206,29 @@ LIMIT $3;
|
||||
-- M6a: top-N tracks by completed-play count for the user. was_skipped
|
||||
-- excludes skips so a user spamming next doesn't fabricate a top track.
|
||||
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||||
--
|
||||
-- Aggregate play_events first (uses play_events_user_track_idx) and
|
||||
-- join through tracks/albums/artists only for the survivors. Earlier
|
||||
-- shape did the join across every play_event row before grouping —
|
||||
-- O(plays) instead of O(distinct tracks).
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT sqlc.embed(t),
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
JOIN play_events pe ON pe.track_id = t.id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title, artists.name
|
||||
ORDER BY count(*) DESC, t.id
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListLastPlayedArtistsForUser :many
|
||||
@@ -231,17 +236,28 @@ LIMIT $2;
|
||||
-- a derived cover_album_id via a representative-album lateral join (most
|
||||
-- recent album that has cover_art_path set). album_count joined for the
|
||||
-- ArtistRef wire shape.
|
||||
SELECT sqlc.embed(a),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
max_started.started_at::timestamptz AS last_played_at
|
||||
FROM artists a
|
||||
JOIN LATERAL (
|
||||
SELECT max(pe.started_at) AS started_at
|
||||
--
|
||||
-- Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||||
-- subquery per artist — O(total_artists) plan even for users with a
|
||||
-- handful of plays. New shape aggregates the user's plays by artist
|
||||
-- via the play_events → tracks join up front (uses
|
||||
-- play_events_user_track_idx + tracks pkey lookups), then attaches
|
||||
-- the artist row and lateral cover/count subqueries only for the
|
||||
-- artists that actually appear. Distinct-artists set is small for a
|
||||
-- typical user, so cost is bounded by play history not library size.
|
||||
WITH user_plays AS (
|
||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND t.artist_id = a.id
|
||||
) max_started ON max_started.started_at IS NOT NULL
|
||||
WHERE pe.user_id = $1
|
||||
GROUP BY t.artist_id
|
||||
)
|
||||
SELECT sqlc.embed(a),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count,
|
||||
up.last_started::timestamptz AS last_played_at
|
||||
FROM user_plays up
|
||||
JOIN artists a ON a.id = up.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = a.id AND cover_art_path IS NOT NULL
|
||||
@@ -251,7 +267,7 @@ LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = a.id
|
||||
) cnt ON true
|
||||
ORDER BY max_started.started_at DESC, a.id
|
||||
ORDER BY up.last_started DESC, a.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListRediscoverAlbumsForUser :many
|
||||
|
||||
@@ -47,7 +47,10 @@ UPDATE system_playlist_runs
|
||||
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||
|
||||
-- name: PickSeedArtists :many
|
||||
-- Top-3 most-engaged distinct artists in the user's last 7 days.
|
||||
-- Top-5 most-engaged distinct artist candidates in the user's last 7
|
||||
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
||||
-- daily-deterministically and takes the first 3 so the set of
|
||||
-- "Songs like X" mixes rotates day-to-day.
|
||||
-- Score = unskipped-play count + 5 if user has liked the artist.
|
||||
WITH plays AS (
|
||||
SELECT t.artist_id,
|
||||
@@ -67,11 +70,15 @@ SELECT p.artist_id,
|
||||
FROM plays p
|
||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
||||
ORDER BY score DESC, p.artist_id
|
||||
LIMIT 3;
|
||||
LIMIT 5;
|
||||
|
||||
-- name: PickTopPlayedTrackForUser :one
|
||||
-- For-You seed selection. Returns the user's most-played non-skipped
|
||||
-- track in the last 7 days; tie-break by track_id for determinism.
|
||||
-- name: PickTopPlayedTracksForUser :many
|
||||
-- For-You candidate seeds. Returns the user's top-5 most-played
|
||||
-- non-skipped tracks in the last 7 days; tie-break by track_id for
|
||||
-- determinism. The Go-side picker (pickForYouSeedForDay) chooses one
|
||||
-- of the returned rows as today's seed via userIDHash so the
|
||||
-- candidate pool rotates day-to-day while staying stable within a
|
||||
-- day.
|
||||
SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
@@ -80,7 +87,7 @@ SELECT t.id
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
ORDER BY COUNT(*) DESC, t.id
|
||||
LIMIT 1;
|
||||
LIMIT 5;
|
||||
|
||||
-- name: PickTopPlayedTrackForArtistByUser :one
|
||||
-- "Songs like X" seed selection. Returns the user's most-played non-skipped
|
||||
|
||||
@@ -142,3 +142,24 @@ RETURNING *;
|
||||
-- to keep the unique index happy and to make the lookup
|
||||
-- case-insensitive.
|
||||
SELECT * FROM users WHERE lower(email) = lower($1);
|
||||
|
||||
-- name: UpdateUserTimezone :exec
|
||||
-- Sets the user's IANA timezone and bumps timezone_updated_at. The
|
||||
-- handler validates the timezone string via time.LoadLocation before
|
||||
-- calling this; the DB does not re-validate.
|
||||
UPDATE users
|
||||
SET timezone = $2,
|
||||
timezone_updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListActiveUsersWithTimezones :many
|
||||
-- Returns (id, timezone) for every user with a play in the last 7
|
||||
-- days. The system-playlist scheduler iterates this list at startup
|
||||
-- and during its hourly reconciliation to discover newly-active /
|
||||
-- no-longer-active users.
|
||||
SELECT u.id, u.timezone FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = u.id
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package eventbus is an in-process publish/subscribe bus for real-time
|
||||
// events the server emits to subscribed clients (today: Flutter via SSE).
|
||||
//
|
||||
// Writers (likes handler, requests reconciler, scanner, etc.) call Publish
|
||||
// to announce a state change. Each connected SSE client holds a
|
||||
// subscription via Subscribe and forwards events to its HTTP response.
|
||||
//
|
||||
// The bus is intentionally simple:
|
||||
//
|
||||
// - Per-subscriber bounded channel buffer. If a subscriber's buffer is
|
||||
// full when an event arrives, the event is dropped FOR THAT SUBSCRIBER
|
||||
// ONLY — writers never block, and other subscribers are unaffected.
|
||||
// - No persistence. A client that connects after an event was emitted
|
||||
// will not see that event. SSE clients are expected to fetch a fresh
|
||||
// snapshot via the normal /api/* endpoints on (re)connect.
|
||||
// - No cross-process broadcast. v1 runs as a single Go binary; a future
|
||||
// multi-instance deployment will need a real broker (NATS / Redis
|
||||
// pub-sub / etc.) but that's out of scope here.
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event is a single broadcast message. Kind names follow a "domain.action"
|
||||
// convention (e.g. "track.liked", "request.status_changed"). UserID, when
|
||||
// non-empty, scopes the event to a specific user — subscribers receive
|
||||
// only events with UserID == their auth user OR with empty UserID (which
|
||||
// means "broadcast to everyone subscribed").
|
||||
type Event struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// Bus is a fan-out broadcaster. Safe for concurrent use; the zero value is
|
||||
// not usable — call New().
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[chan Event]struct{}
|
||||
}
|
||||
|
||||
// New returns a Bus with no subscribers.
|
||||
func New() *Bus {
|
||||
return &Bus{subscribers: map[chan Event]struct{}{}}
|
||||
}
|
||||
|
||||
// Publish fans an event out to every subscriber. Non-blocking per
|
||||
// subscriber: if a subscriber's buffer is full, the event is dropped for
|
||||
// that subscriber rather than stalling the writer.
|
||||
func (b *Bus) Publish(e Event) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
for ch := range b.subscribers {
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
// Subscriber buffer full — drop. The subscriber will resync
|
||||
// via a normal API fetch when the client decides to.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new subscriber and returns its receive channel
|
||||
// plus an unsubscribe func. The buffer argument controls how many events
|
||||
// can be queued before drops occur; 16–64 is a reasonable range for an
|
||||
// SSE client that processes events promptly.
|
||||
//
|
||||
// Callers MUST invoke the returned unsubscribe func when done (typically
|
||||
// in a defer), or the subscription leaks.
|
||||
func (b *Bus) Subscribe(buffer int) (<-chan Event, func()) {
|
||||
if buffer <= 0 {
|
||||
buffer = 16
|
||||
}
|
||||
ch := make(chan Event, buffer)
|
||||
b.mu.Lock()
|
||||
b.subscribers[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
unsub := func() {
|
||||
b.mu.Lock()
|
||||
if _, ok := b.subscribers[ch]; ok {
|
||||
delete(b.subscribers, ch)
|
||||
close(ch)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
return ch, unsub
|
||||
}
|
||||
|
||||
// SubscriberCount returns the current number of active subscribers. For
|
||||
// tests + observability; not load-bearing in hot paths.
|
||||
func (b *Bus) SubscriberCount() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.subscribers)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPublish_DeliversToSubscriber(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
ch, unsub := b.Subscribe(4)
|
||||
defer unsub()
|
||||
|
||||
b.Publish(Event{Kind: "test.event", UserID: "u1", Data: map[string]any{"x": 1}})
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Kind != "test.event" || got.UserID != "u1" {
|
||||
t.Fatalf("unexpected event: %+v", got)
|
||||
}
|
||||
if got.Data["x"] != 1 {
|
||||
t.Fatalf("payload mismatch: %+v", got.Data)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublish_FanOutToMultipleSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
ch1, unsub1 := b.Subscribe(4)
|
||||
ch2, unsub2 := b.Subscribe(4)
|
||||
defer unsub1()
|
||||
defer unsub2()
|
||||
|
||||
b.Publish(Event{Kind: "broadcast"})
|
||||
|
||||
for i, ch := range []<-chan Event{ch1, ch2} {
|
||||
select {
|
||||
case got := <-ch:
|
||||
if got.Kind != "broadcast" {
|
||||
t.Fatalf("ch%d: kind=%q", i, got.Kind)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("ch%d: timed out", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublish_DropsWhenSubscriberBufferFull(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
_, unsub := b.Subscribe(2) // tiny buffer
|
||||
defer unsub()
|
||||
|
||||
// Without draining the channel, push more events than the buffer holds.
|
||||
// Drops are silent; the assertion is that Publish does not block.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
b.Publish(Event{Kind: "flood"})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good — Publish returned without blocking on the laggard.
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Publish blocked on a slow subscriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribe_RemovesFromBusAndClosesChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
ch, unsub := b.Subscribe(4)
|
||||
|
||||
if got := b.SubscriberCount(); got != 1 {
|
||||
t.Fatalf("after Subscribe: SubscriberCount=%d, want 1", got)
|
||||
}
|
||||
|
||||
unsub()
|
||||
|
||||
if got := b.SubscriberCount(); got != 0 {
|
||||
t.Fatalf("after unsub: SubscriberCount=%d, want 0", got)
|
||||
}
|
||||
|
||||
// Channel is closed: receive returns zero value with ok=false.
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("expected channel to be closed after unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribe_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
_, unsub := b.Subscribe(4)
|
||||
unsub()
|
||||
unsub() // must not panic / double-close
|
||||
if got := b.SubscriberCount(); got != 0 {
|
||||
t.Fatalf("SubscriberCount=%d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublish_ConcurrentWritersAndSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := New()
|
||||
|
||||
const (
|
||||
subs = 8
|
||||
perWriter = 50
|
||||
writers = 4
|
||||
bufferSize = 100
|
||||
)
|
||||
|
||||
var subWG sync.WaitGroup
|
||||
subWG.Add(subs)
|
||||
stops := make([]func(), 0, subs)
|
||||
for i := 0; i < subs; i++ {
|
||||
ch, unsub := b.Subscribe(bufferSize)
|
||||
stops = append(stops, unsub)
|
||||
go func() {
|
||||
defer subWG.Done()
|
||||
for range ch {
|
||||
// Drain until channel closes.
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var pubWG sync.WaitGroup
|
||||
pubWG.Add(writers)
|
||||
for w := 0; w < writers; w++ {
|
||||
go func() {
|
||||
defer pubWG.Done()
|
||||
for i := 0; i < perWriter; i++ {
|
||||
b.Publish(Event{Kind: "concurrent"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
pubWG.Wait()
|
||||
|
||||
// Tear down subscribers — channels close, goroutines exit.
|
||||
for _, stop := range stops {
|
||||
stop()
|
||||
}
|
||||
subWG.Wait()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
)
|
||||
|
||||
// Package-level bus pointer for scan lifecycle events (#392). Set once
|
||||
// at startup via SetEventBus; reads are nil-safe so test contexts that
|
||||
// never call SetEventBus simply skip publishing.
|
||||
//
|
||||
// A package-global is the right shape here because:
|
||||
//
|
||||
// - There is exactly one bus per process (the same instance powers
|
||||
// the SSE subscriber endpoint and every publisher across the codebase).
|
||||
// - Threading it through RunScan + TryStartScan + the Scheduler +
|
||||
// every test caller would touch ~10 call sites without changing
|
||||
// behavior at the boundaries that don't care.
|
||||
// - Other Go stdlib packages use the same pattern (log.SetDefault,
|
||||
// etc.) for process-wide singletons.
|
||||
var (
|
||||
busMu sync.RWMutex
|
||||
bus *eventbus.Bus
|
||||
)
|
||||
|
||||
// SetEventBus wires the live-event bus into the library package so scan
|
||||
// runs emit scan.run_started / scan.run_finished events. Call once during
|
||||
// process startup; subsequent calls overwrite the previous bus.
|
||||
func SetEventBus(b *eventbus.Bus) {
|
||||
busMu.Lock()
|
||||
defer busMu.Unlock()
|
||||
bus = b
|
||||
}
|
||||
|
||||
// publishScanEvent broadcasts a scan lifecycle event. No-op when no bus
|
||||
// has been set. Scan events are always broadcast (empty UserID) because
|
||||
// only admins see the scan status card; non-admin clients' invalidation
|
||||
// dispatcher will receive the event but won't have any provider keyed on
|
||||
// scan status, so the receive is harmless.
|
||||
func publishScanEvent(kind string, runID pgtype.UUID, data map[string]any) {
|
||||
busMu.RLock()
|
||||
b := bus
|
||||
busMu.RUnlock()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"run_id": uuidToBusString(runID)}
|
||||
for k, v := range data {
|
||||
payload[k] = v
|
||||
}
|
||||
b.Publish(eventbus.Event{
|
||||
Kind: kind,
|
||||
UserID: "", // broadcast
|
||||
Data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// uuidToBusString renders a pgtype.UUID in canonical 8-4-4-4-12 form.
|
||||
// Inlined to avoid a back-edge dependency on internal/api or another
|
||||
// helper-bearing package.
|
||||
func uuidToBusString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 36)
|
||||
pos := 0
|
||||
for i, b := range u.Bytes {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
out[pos] = '-'
|
||||
pos++
|
||||
}
|
||||
out[pos] = hex[b>>4]
|
||||
out[pos+1] = hex[b&0xf]
|
||||
pos += 2
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func RunScan(
|
||||
return pgtype.UUID{}, fmt.Errorf("insert scan_run: %w", err)
|
||||
}
|
||||
logger.Info("scan run started", "id", row.ID)
|
||||
publishScanEvent("scan.run_started", row.ID, nil)
|
||||
|
||||
var firstErr error
|
||||
captureErr := func(stage string, stageErr error) {
|
||||
@@ -228,5 +229,8 @@ func RunScan(
|
||||
}
|
||||
|
||||
logger.Info("scan run complete", "id", row.ID, "error", errMsg)
|
||||
publishScanEvent("scan.run_finished", row.ID, map[string]any{
|
||||
"error_message": errMsg,
|
||||
})
|
||||
return row.ID, firstErr
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
||||
)
|
||||
|
||||
@@ -21,22 +22,65 @@ type Reconciler struct {
|
||||
pool *pgxpool.Pool
|
||||
lidarrCfg *lidarrconfig.Service
|
||||
logger *slog.Logger
|
||||
bus *eventbus.Bus
|
||||
tick time.Duration
|
||||
batch int32
|
||||
}
|
||||
|
||||
// NewReconciler constructs a Reconciler with production defaults:
|
||||
// 5-minute tick, batch size 50.
|
||||
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger) *Reconciler {
|
||||
// 5-minute tick, batch size 50. Pass nil for bus when no SSE publishing
|
||||
// is desired (tests, or environments without the event stream enabled).
|
||||
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
|
||||
return &Reconciler{
|
||||
pool: pool,
|
||||
lidarrCfg: cfg,
|
||||
logger: logger,
|
||||
bus: bus,
|
||||
tick: 5 * time.Minute,
|
||||
batch: 50,
|
||||
}
|
||||
}
|
||||
|
||||
// publishCompleted broadcasts a request.status_changed event scoped to
|
||||
// the original requester. No-op when bus is nil.
|
||||
func (r *Reconciler) publishCompleted(row dbq.LidarrRequest) {
|
||||
if r.bus == nil {
|
||||
return
|
||||
}
|
||||
r.bus.Publish(eventbus.Event{
|
||||
Kind: "request.status_changed",
|
||||
UserID: formatUUIDForBus(row.UserID),
|
||||
Data: map[string]any{
|
||||
"request_id": formatUUIDForBus(row.ID),
|
||||
"status": "completed",
|
||||
"kind": string(row.Kind),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
|
||||
// string. Mirrors helpers in internal/api/convert.go and other packages;
|
||||
// inlined here to avoid a back-edge dependency from lidarrrequests onto
|
||||
// the api layer.
|
||||
func formatUUIDForBus(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return ""
|
||||
}
|
||||
const hex = "0123456789abcdef"
|
||||
out := make([]byte, 36)
|
||||
pos := 0
|
||||
for i, b := range u.Bytes {
|
||||
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||
out[pos] = '-'
|
||||
pos++
|
||||
}
|
||||
out[pos] = hex[b>>4]
|
||||
out[pos+1] = hex[b&0xf]
|
||||
pos += 2
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
|
||||
// tickOnce are logged at WARN and never propagated.
|
||||
func (r *Reconciler) Run(ctx context.Context) {
|
||||
@@ -114,13 +158,17 @@ func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row db
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
ID: row.ID,
|
||||
MatchedArtistID: artistID,
|
||||
MatchedAlbumID: pgtype.UUID{},
|
||||
MatchedTrackID: pgtype.UUID{},
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
|
||||
@@ -138,13 +186,17 @@ func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq
|
||||
}
|
||||
return err
|
||||
}
|
||||
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
ID: row.ID,
|
||||
MatchedAlbumID: albumID,
|
||||
MatchedArtistID: pgtype.UUID{},
|
||||
MatchedTrackID: pgtype.UUID{},
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
|
||||
@@ -178,13 +230,17 @@ func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
completed, err := q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
|
||||
ID: row.ID,
|
||||
MatchedAlbumID: albumID,
|
||||
MatchedTrackID: trackID,
|
||||
MatchedArtistID: pgtype.UUID{},
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.publishCompleted(completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestReconciler_MatchesArtistByMBID(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "Boards of Canada",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func TestReconciler_MatchesAlbumByMBID(t *testing.T) {
|
||||
LidarrAlbumMBID: albumMBID, AlbumTitle: "Test Album",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestReconciler_MatchesTrackViaAlbumMBID(t *testing.T) {
|
||||
LidarrTrackMBID: trackMBID, TrackTitle: "Track One",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestReconciler_NoMatchLeavesPending(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: "nonexistent-mbid-xyz", ArtistName: "Ghost Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func TestReconciler_AlreadyCompletedRowNotReprocessed(t *testing.T) {
|
||||
t.Fatalf("CompleteLidarrRequest setup: %v", err)
|
||||
}
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -313,7 +313,7 @@ func TestReconciler_DisabledIsNoOp(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: artistMBID, ArtistName: "No-Op Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -345,7 +345,7 @@ func TestReconciler_RunRunsAtLeastOneTick(t *testing.T) {
|
||||
Kind: "artist", LidarrArtistMBID: mbid, ArtistName: "Run Artist",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
rec.tick = 25 * time.Millisecond
|
||||
|
||||
done := make(chan struct{})
|
||||
@@ -399,7 +399,7 @@ func TestReconciler_TrackKindNoTracksInAlbumYet(t *testing.T) {
|
||||
LidarrTrackMBID: "t-mbid-not-yet", TrackTitle: "Some Track",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
@@ -428,7 +428,7 @@ func TestReconciler_AlbumKindNoMatchInAlbumsTable(t *testing.T) {
|
||||
LidarrAlbumMBID: "al-not-in-library", AlbumTitle: "Nope Album",
|
||||
})
|
||||
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger())
|
||||
rec := NewReconciler(pool, lidarrconfig.New(pool), newTestLogger(), nil)
|
||||
if err := rec.tickOnce(ctx); err != nil {
|
||||
t.Fatalf("tickOnce: %v", err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user